docs: 全量梳理文档结构;补 Gitea 迁移指南 / 架构精简候选;架构总览补证书管家子系统

用户四问:「把项目文件全部理一遍 还有readme文件」「gitea 后面可能还要迁移 帮我写一个迁移文档」
「证书管家做完了吗」「感觉现在架构还是复杂了」

一、文档梳理:根目录 15 个 md → 2 个

根目录只留 README.md(入口)+ 架构总览.md(现状唯一事实源),其余全部归位:

  docs/
  ├── README.md              ← 新增:文档地图(入口 / 当前有效 / 历史归档 三层)
  ├── 证书管家.md             ← 原「函数版证书管家-方案.md」改名
  │                              (它早已是「现状+沿革」文档,标题名不副实 —— 签发早已不在 CF Worker)
  ├── Gitea迁移指南.md        ← 新增
  ├── 架构精简候选.md         ← 新增
  ├── CNB构建落地方案.md / GITEA_SECRETS.md
  └── archive/{平台与选型,功能与修复,主题与内容}/   ← 32 份

- 32 个归档文档统一加 `> 📦 本文档已归档` banner,并指向架构总览
  —— 这个仓库历史文档里全是「权威口径」,混看很容易拿废弃结论当现状
- gitea-backup/ → deploy/gitea/(原名「backup」不准,它是部署包;与其它 deploy 单元并列)
- 新增 md 链接校验(python 脚本,中文路径用 sed 不安全)→ 首轮 18 处断链
  (多一层目录要让 banner 里的相对路径补一个 ../)→ 修正后 0 断链

二、新增 docs/Gitea迁移指南.md(★ 有死线:境外 VPS 11 月到期)

★ 核心价值是「指出迁移面已大幅缩小」:仓库原有的 deploy/gitea/迁移前检查清单.md
(2026-10-04)是为「Gitea + act_runner + 又拍云同步 *整套* 搬迁」写的,**那个前提已经不存在**——
构建归 CNB,runner / upyun-sync / 中转机 / COS 都不需要了。迁移实际只剩「搬数据卷 + 改地址」。

★★ 全文最重要:本仓有 9 处写死了旧地址,按易漏程度排序(1-3 本机,4-5 线上)
  1 本机 .git/config 的 gitea remote
  2 scripts/setup-cnb-remotes.sh 的 GITEA_URL 默认值
  3 deploy/editor-api/bootstrap.sh 的 GITEA_URL 默认值
  4 ★ /srv/editor-api/.env 的 GITEA_URL      ← 漏了会「持续报错但只警告不阻断」,没人发现
  5 ★ /srv/blog 的 gitea remote               ← 同上
  6 docs/GITEA_SECRETS.md
  7-9 架构总览.md / README.md / editor-api/README.md
(不用改:docker-compose.editor.yml、server.mjs、pushall 别名 —— 只引用 remote 名字)

另含:建议新实例改用域名而非 IP(以后搬机器只改 DNS;⚠️ Gitea 28 起只读 ROOT_URL 不读
[server] DOMAIN);rsync 而非 dump(属主必须 uid/gid 1000);切换顺序「先建新的→验证→再拆旧的」;
验证清单强调 `git push gitea --dry-run`;第九节给出「干脆不留这个辅仓」的选项与判断依据。

顺带修掉两个硬伤:
- deploy/gitea/docker-compose.yml 的镜像 tag `1.28.0-rootless` 根本不存在
  (Gitea 28 起去掉 1. 前缀)→ 改 28.0.0-rootless(pin 死,不用 latest)
- deploy/gitea/.gitignore 漏了 backups/(跑一次备份就会把含 secrets 的 dump 写进历史)

三、证书管家核实(结论:已上线运行,2 个遗留)

线上实测:容器 Up (healthy);/preflight 7/7 全过;3 组域名;下次自动续期 04:10。

遗留 ① writeapi.usj.cc 证书没纳管(真问题):nginx 配置在 /www/conf.d/writeapi.usj.cc.conf,
不在 /www/sites/ 管理树里 → 1Panel 的 ssl/upload+sslID 物化碰不到它。
实测仍是 RSA / CN=usj.cc / 到期 2026-12-07,本项目续期不会更新 → 12 月会断。
遗留 ② dnsapi.usj.cc 没上 HTTPS。
(澄清假问题:200181.xyz 公网看到的 LE 证书是 CF 自家边缘证书,与本项目无关)

四、新增 docs/架构精简候选.md(回应「架构还是复杂了」)

结论:复杂度不在件数,在「跨 6 个环境,其中 4 个要自己维护」。国内机 9 个容器里属于本项目的
只有 3 个,能动的只有 2 个。5 个候选 + 建议执行顺序:
  1 停 certimate 容器(工作流已全停用)——先 stop 观察,别急着删
  2 cn-dns-helper 大概率已是遗留(「Worker 侧签发」时代产物;现在签发在国内机且自带 dnsprovider,
    /preflight 显示 CF 凭据可读;Worker 侧 dnsremoted.ts 已无任何引用)
  3 Gitea:迁 or 不留
  4 两套写作前端收敛(本轮不动,先看使用频率)
  5 writeapi.usj.cc 证书纳管(12 月死线)
并明确列出 6 项「必要复杂度,不建议动」。

五、架构总览补齐证书管家子系统(★ 之前完全缺席)

一个完整子系统在「事实源文档」里一个字都没有 —— 这本身就是文档债。补:
- §0 一句话(四→五个子系统)、§1.1 子系统表、§1.2 地址地图两行
- **新增 §5.7 证书管家**:职责划分 / 为什么非要这么分(Worker 免费版 CPU 10ms)/
  三条不能破的红线(Worker 不得签发 · 证书路由只认 Bearer 会话 · 角色必须正着枚举放行)/
  纳管域名表 / 与 certimate 的关系 / 已知遗留
- .cnb.yml 行数 284 → 337(数字漂了);头部更新时间 → 2026-10-06;附录表改指 docs/
- §6 待办:#13(证书遗留)、#14(复杂度盘点)新增;#7 改写为「Gitea 11 月到期,★ 有死线」

六、记忆

.workbuddy/memory/MEMORY.md 新增「文档结构」「Gitea 迁移死线」两节;证书管家节补实测与遗留。
This commit is contained in:
zqlit committed 2026-10-06 22:27:36 +08:00
1 parent 15a8b502db
commit 1568b150b3
48 files changed
+782 -100

No files matched your search

+463
View File
@@ -0,0 +1,463 @@
# CNB 构建落地方案:双 CDN 架构不动,砍掉中转链
> 状态:2026-10-04 · 待你确认后落地
> 回答的问题:「CNB 构建的前提下,能不能继续现在的架构(境外 EdgeOne + 境内多吉云 → 又拍云源站)?」
---
## 一句话结论
**能,而且一个字都不用改。** 域名解析、备案、EdgeOne 项目、多吉云配置、又拍云存储全部保持原样。
CNB 只替换「构建 + 搬运」这一段,顺带砍掉广州中转机上那个每分钟轮询的 `sync.sh`。
---
## 零、GitHub 依赖审计:全链路零依赖
回答「GitHub 账号被标记了,还能连上 CNB 吗」——**能,而且这条链路从头到尾不需要 GitHub。**
逐环节核实(来源:腾讯云官方文档「社区版快速入门」「个人中心」、CNB 官方 Wiki):
| 环节 | CNB 的做法 | 碰 GitHub 吗 |
|---|---|---|
| 注册 / 登录 | **微信扫码**,扫码即自动注册,无邮箱验证、无登录密码 | ❌ |
| 实名认证 | 微信扫码授权手机号 | ❌ |
| 建组织 / 仓库 | CNB 内建(无「个人私有仓库」,仓库必须挂在组织下) | ❌ |
| 导入代码 | 标准 git:`git push --mirror <cnb-url>`,源仓可以是**自建 Gitea** | ❌ |
| 拉构建依赖 | CNB 自带「国内外依赖源默认加速」(腾讯云 CDN + Docker/NPM/Maven 镜像) | ❌ |
| hugo 二进制 | **已随仓库携带**(`bin/linux/hugo`),构建时零跨境下载 | ❌ |
| 部署 | EdgeOne API + 又拍云 upx v0 | ❌ |
官方原文:
> 云原生构建默认使用微信扫码注册登录,未注册用户在扫码登录后,将自动完成注册。
> —— 腾讯云文档《社区版快速入门》
> 云原生构建为微信扫码登录注册,没有登录密码。
> —— 腾讯云文档《个人中心》
### 唯一会碰到 GitHub 的两个场景,你都不走
1. **用 CNB 的「从 GitHub 导入」一键迁移工具** —— 那个需要 `GITHUB_TOKEN`。
**但你从自建 Gitea 迁,用 `git push --mirror`,不需要它。**
2. **构建时在线拉 GitHub 资源** —— 这正是**已经提前拆掉**的雷:hugo 二进制改为随仓库携带。
### 顺带纠正一个概念
**「账号被标记」≠「IP 被墙」。** 标记是账号级限制;匿名 HTTP 访问 github.com 的公开资源不受影响
(实测国内节点 `curl` hugo release 仍返回 302 正常)。所以即便真要在构建里拉 GitHub,也只是**慢**,不是**不通**。
### 你真正要做的三件准备(都与 GitHub 无关)
1. 微信扫码注册 CNB
2. 微信扫码完成**实名认证**(官方说明:未实名用户禁止写行为 —— 创建仓库 / 推送代码)
3. 先建一个**组织**,仓库建在组织下
---
## 一、决定性发现:答案你自己早就写在代码里了
`deploy.yml` 第 290 行起,那段被 `if: false` 停用的 `Upload to UpYun`,注释原话:
```
│ 2026-10-03 起永久跳过海外直传:本 runner 在境外,对 UpYun v0 接口
│ 无论 -w 10/--strong 还是 -w 3 均为 70 秒左右 EOF,3 轮重试全败,
│ 每次部署白耗 5 分钟(2026-10-03 build #25 日志实锤)。
│ 国内线路由腾讯云广州中转机兜底(1Panel 计划任务「又拍云同步(国内
│ 中转)」每分钟比对 COS build hash → 下载 355M → upx sync →
│ 又拍云 purge → 多吉云刷新,实测 5 分钟内完成且它自己会刷 CDN)。
│ 将来若把 runner 挪到国内,把下面的 if: false 删掉即可恢复。
```
**当初被迫上中转机,唯一的原因是「runner 在境外」。**
链路是这样被逼出来的:
| 环节 | 为什么存在 |
|---|---|
| runner 在境外 | Gitea 跑在境外 VPS |
| 直传又拍云失败 | 境外 → 又拍云 v0 接口,**跨境 + 并发敏感 → 70 秒 EOF,必败** |
| COS 中转 | 境外的产物,国内机器得有个地方取 |
| 广州中转机 + sync.sh | 只有它在国内,能直传又拍云 |
**CNB 的构建节点就在国内(腾讯云)。** 那句注释的复活条件直接成立 —— 中转机、COS、artifact 传递全都失去了存在理由。
---
## 二、目标架构
```
写作 / 本地
│ git push
▼
CNB(腾讯云,构建节点在国内 4 核 8 GiB)
│ 一次构建,三条出口
├──► 又拍云存储(境内源站) upx sync + upx purge
│ │ 多吉云回源自动跟随
│ └──► 多吉云 CDN 刷新(API 精确刷新)
│
└──► EdgeOne Pages(境外) npx edgeone pages deploy --area overseas
```
**保留不动**(零改动):
- 又拍云存储 `imzql`(境内源站)
- 多吉云 CDN(境内解析,回源又拍云)
- EdgeOne Pages 项目 `hugo-blog`(境外解析,`--area overseas`)
- 域名解析、ICP 备案、Ying 主题、content 内容
**替换掉**:
- Gitea Actions `build` job → CNB 流水线
- act_runner → 不需要
- COS / Gitea artifact 传递 → 不需要(含那串 v3/v4 坑)
- 广州中转机 `sync.sh` 轮询 → 不需要
- `deploy.yml` 里 8 处 `github.server_url` 兼容分支 → 不需要(另写 `.cnb.yml`)
---
## 三、CNB 能力核对(全部官方文档核实)
| 能力 | 值 | 对本项目的意义 |
|---|---|---|
| 构建节点 | 默认 8 核 16 GiB(**内存 = CPU 核数 × 2**) | 全面优于广州机 2 核 1.9 G |
| 免费额度 | **160 核时/月**(0.125 元/核时超出) | 见下方换算 |
| 构建最大时长 | 20 小时;单任务无输出 10 分钟 / 超 1 小时触发超时(可声明,上限 12 小时) | 传 372MB 绰绰有余 |
| 自定义环境 | `docker.image` 或 `docker.build`(Dockerfile) | 可固定 hugo 0.128.2 extended |
| 密钥管理 | 密钥仓库 + `imports` 导入环境变量 | 又拍云/多吉云/EdgeOne 凭据不落仓库 |
| 定时任务 | 支持 cron,最小间隔 5 分钟,时区 Asia/Shanghai | 可替代每日定时构建 |
| **出网限制** | **无白名单限制**(官方:出口 IP 动态,明确不建议白名单) | 又拍云 / 多吉云 / EdgeOne API 都可直连 |
| Docker-in-Docker | 支持(`services: docker`) | 备用 |
| 官方集成 | 腾讯云官方有 **CNB → EdgeOne** 的部署文档示例(`npx edgeone makers deploy`) | 已验证路径 |
### 额度换算(关键决策数字)
| 规格 | 内存 | 每次构建耗(按 10 分钟) | 160 核时可用次数 |
|---|---|---|---|
| 8 核 | 16 GiB | 1.33 核时 | **120 次/月** |
| **4 核** | **8 GiB** | **0.67 核时** | **240 次/月** |
| 2 核 | 4 GiB | 0.33 核时 | 480 次/月 |
你的触发频率是「每天 1 次定时 + 每次发文」≈ **60–90 次/月**。
**建议用 4 核 8 GiB** —— 240 次/月的余量,即使构建耗时翻倍到 20 分钟也还有 120 次。hugo 构建吃 CPU(4 核够并行编译),`upx sync` 吃网络不吃 CPU。
---
## 四、落地骨架
### 4.0 第一步:仓库迁移与远端配置(CNB 主仓 + 自建 Gitea 辅仓)
**目标形态**
```
origin → https://cnb.cool/<组织>/<仓库> ← **主仓**(fetch + push),CNB 构建由它触发
gitea → http://23.254.236.47:3001/zqlit/blog ← **代码同步辅仓**(只推不拉,不参与构建)
```
> **2026-10-06 定案:CNB 主仓 + 自建 Gitea 辅仓,另加一份离线 bundle 备份。**
>
> | remote | 地址 | 角色 |
> |---|---|---|
> | `origin` | `https://cnb.cool/zqlit/blog.git` | **主仓** —— 唯一的 fetch 源,推送即触发 CNB 构建 |
> | `gitea` | `http://23.254.236.47:3001/zqlit/blog.git` | **代码同步辅仓** —— 只作代码留档与找回,不参与构建 |
>
> - **GitHub**(原 `gh` remote):账号被平台标记、仓库被按 AUP 清空 → 已彻底移除,**别再往回加**
> - **Gitee**:单仓 500MB / 单文件 50MB 两道硬门槛过不去(本仓 `bin/linux/hugo` 83.1MB)→ 未采用
>
> **备选异地备份 = 「整仓加密 bundle → 中兴 F50 上的 OpenList」**,与 git 远端无关。
> 它和 Gitea 辅仓不是一回事、也不能互相替代:
> 辅仓保「随时能 `clone` 回来」,bundle 保「平台全挂/账号被封时仍有一份完整历史」。
> 选型依据见 `架构总览.md` §5.3,实现与恢复流程见 §5.6。
`git pushall` 别名 = `git push origin main; git push gitea main` ——
用 `;` 而非 `&&`,主仓失败时辅仓照样推;退出码以主仓为准(辅仓失败不阻断发布)。
**★ 为什么不用「一个 remote 挂多个 pushurl」**(这条教训仍然有效)
早期的 `origin` 就是那种写法(pushurl 同时挂了 GitHub 和自建 Gitea)。但它有个被忽视的缺陷,我做了实测:
| 场景 | 结果 |
|---|---|
| 两个 pushurl 都可用 | ✅ 两个都收到,一次 push 搞定 |
| **第一个失败** | ❌ **git 立即中止,第二个根本不会被推** |
也就是说:**主仓推失败时,辅仓那份备份也不会更新** —— 备份的意义就没了。
所以当时才拆成两个独立 remote,别名里用 `;` 而不是 `&&`,保证互不阻塞。
**现行方案正是回到这个写法**:`origin`(CNB) 与 `gitea`(自建) 各自独立成 remote,
别名 `;` 串联、退出码取主仓 —— 这就是现在的 `git pushall`。
**脚本**(已就位,可直接用)
```bash
bash scripts/setup-cnb-remotes.sh https://cnb.cool/<组织>/<仓库>
```
它会先把你现有的远端配置(含明文凭据)备份到 `.workbuddy-backup/git-remotes.<时间戳>.txt`,
再重建 `origin`、把 `pushall` 设为 `git push origin main; git push gitea main`,
并顺手清掉 `gh` / `gitee` 这两个已退役的远端 —— 全程可回退。
> Gitea 的凭据**不写死在脚本里**,要现给:
> `GITEA_PASS='<Gitea 密码或访问令牌>' bash scripts/setup-cnb-remotes.sh <CNB_URL>`
> 没给就只配 `origin`(`pushall` 里的 gitea 段会被自动跳过)。
**★ CNB 不支持 SSH(官方明确)**
> SSH Not Supported —— 平台不支持 SSH 协议访问。
> 认证方式:用户名**固定填 `cnb`**,密码填**访问令牌**。
> —— docs.cnb.cool《Git Address and Authentication Instructions》
这跟 GitHub 的 SSH 不同,所以两段要各用各的认证:
```bash
git config --global credential.helper manager # Windows 的 Git Credential Manager
# 首次 push 时:用户名 cnb,密码 = 访问令牌(个人设置 → 访问令牌 → 勾「代码仓库/读写」)
```
仓库地址直接用仓库页地址即可(`https://cnb.cool/组/仓`),带不带 `.git` 都支持。
**建仓顺序不能乱**(官方要求)
1. 微信扫码注册 → 2. 微信扫码**实名认证**(未实名禁止建仓/推码)→ 3. 先建**组织**(CNB 无「个人私有仓库」,仓库必须挂在组织下)→ 4. 建仓库 → 5. 跑上面的脚本 + push
### ⚠️ 推 CNB 之前必须先知道的敏感文件(已审计)
这些文件**当前就被 git 跟踪**,会在首次 push 时连**全部历史**一起进 CNB:
| 文件 | 内容 | 状态 |
|---|---|---|
| `write-server/nginx/ssl/privkey.pem` | **真实 TLS 私钥**(EC P-256,`BEGIN PRIVATE KEY`),2026-06-22 起在库 | 已跟踪 |
| `.env` | `SILICONFLOW_API_KEY`、`ZHIPU_API_KEY` | 已跟踪 |
| `docs/GITEA_SECRETS.md` | Gitea 相关凭据说明 | 已跟踪 |
| `content/posts/2024/.../setup-secrets.png` | 疑似密钥配置截图 | 已跟踪 |
**关于暴露面**:该仓一直是**私有**的(匿名 API 返回 404)。2026-10-06 账号被平台标记后
仓库更被按 **AUP 合规**清空、本地也移除了 `gh` remote → **这条威胁面已彻底消失**。
**但两件事仍建议做掉**:
1. **CNB 仓库保持「私有」** —— 这些文件在私有仓里影响可控
2. **`git rm --cached` 停止继续跟踪**(历史里的删不掉,但至少不再新增):
```bash
git rm --cached .env docs/GITEA_SECRETS.md write-server/nginx/ssl/privkey.pem write-server/nginx/ssl/fullchain.pem
```
⚠️ 注意:`.gitignore` 里其实**已经有** `.env` / `.env.*` 规则 —— 它们失效的原因不是规则写错,
而是**忽略规则对「已跟踪」文件无效**,所以必须显式 `git rm --cached`。
(另:`.gitignore` 里那条 `write\.env` 是写错的 —— gitignore 中 `\` 是转义符,
它实际匹配文件名 `write.env` 而非 `write/.env`;幸好 `write/.gitignore` 里有 `.env*` 兜住了。)
**历史清理(`git filter-repo`)的判断已变**:当初不做的唯一顾虑是「会让备份分叉」——
2026-10-06 GitHub 那份已消失,**这个顾虑没有了**;且离线 bundle 已加密
(见 `架构总览.md` §5.6),不受影响。
所以**现在是彻底抹掉这些文件历史的最佳窗口**。
代价:全部提交 SHA 改变、自建 Gitea 那份需重推。
---
### 4.1 `deploy/Dockerfile`(构建环境)
CNB 默认镜像不含 hugo / upx,写一个专属镜像固定版本:
```dockerfile
FROM node:22-bookworm-slim
# 换国内 apt 源(CNB 只明示加速 Docker/NPM/Maven,apt 默认源在国内节点可能慢)
RUN set -eux; \
for f in /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources; do \
if [ -f "$f" ]; then \
sed -i -e 's|deb.debian.org|mirrors.aliyun.com|g' \
-e 's|security.debian.org|mirrors.aliyun.com|g' "$f"; \
fi; \
done; \
apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl tar zstd python3 xz-utils git \
&& rm -rf /var/lib/apt/lists/*
# Hugo extended 0.128.2(linux/amd64)——★ 随仓库携带,构建时零跨境下载
COPY bin/linux/hugo /usr/local/bin/hugo
RUN chmod +x /usr/local/bin/hugo && hugo version
# 又拍云 upx
RUN set -eux; \
curl -fsSL -o /tmp/upx.tgz \
"https://collection.b0.upaiyun.com/softwares/upx/upx_0.4.9_linux_amd64.tar.gz"; \
tar -xzf /tmp/upx.tgz -C /usr/local/bin upx; \
chmod +x /usr/local/bin/upx; \
rm /tmp/upx.tgz
# EdgeOne CLI
RUN npm i -g edgeone@latest
CMD ["hugo", "version"]
```
> ✅ 已实测(2026-10-04):
> 1. **hugo 二进制改为随仓库携带**(`bin/linux/hugo`,84MB,进 git 后仅 24MB)。实测国内节点直连 GitHub 拉这个 release 要 **127 秒**(本机只要 7 秒),而每次构建都要付这个成本 → 自带更稳。
> 2. 又拍云 `upx` 的下载源是 `collection.b0.upaiyun.com`(国内 CDN),直连稳定,保留在线安装。
> 3. **架构不能混用**:`E:\Hugo\hugo.exe` 是 `windows/amd64`(本地预览用),CNB 构建节点是 `linux/amd64`(`bin/linux/hugo`),两者互不替代。
> 4. ~~若以后主仓改选 Gitee(单仓 500MB),这 84MB 会挤占配额~~ —— **2026-10-06 定案:不走 Gitee**(它卡单文件 50MB / 单仓 500MB 两道门槛)。现行辅仓是**自建 Gitea**(自己的机器,没有配额这回事,见 §4.0),离线备份走 OpenList 上的加密 bundle(`架构总览.md` §5.6)。这条约束自然消失,`bin/linux/hugo` 继续随仓库携带即可。
> 5. **apt 源改阿里云镜像**:CNB 官方只明示加速「Docker / NPM / Maven 镜像」,Debian 默认 apt 源不在承诺范围内,而构建节点在国内 → 换阿里云兜底,避免构建卡在 `apt-get update`(首轮构建时留意这一步耗时)。
### 4.2 `.cnb.yml`(流水线)
```yaml
main:
push:
- name: 构建并发布(境内外双线路)
imports:
# CNB「密钥仓库」(私有;禁止 clone、只能 Web 编辑),存放 10 项凭据。
# 已在 .cnb.yml 里填好:zqlit/blog-secrets,本地草稿见 cnb-secrets.yml(已 gitignore)
- https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml
runner:
cpus: 4 # 4 核 → 8 GiB
docker:
build: deploy/Dockerfile
stages:
- name: Hugo 构建
script: |
set -euo pipefail
hugo version
python3 scripts/add_draft_to_hidden.py
rm -rf public resources/_gen
hugo --minify --gc
BUILD_HASH=$(find public -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')
echo "$BUILD_HASH" > public/.build_hash
echo "$BUILD_HASH" > public/build_hash.txt
echo "$BUILD_HASH" > .build_hash.out
echo "✅ 构建完成 hash=${BUILD_HASH:0:12} 文件数=$(find public -type f | wc -l)"
- name: 同步到又拍云(境内源站)
script: |
set -euo pipefail
upx login "$UPYUN_SERVICE" "$UPYUN_OPERATOR" "$UPYUN_PASSWORD"
upx sync public/ / -w 5 --delete
upx put public/.build_hash /__build_hash || true
echo "✅ 又拍云同步完成"
- name: 刷新又拍云 CDN
script: |
set -euo pipefail
# ★ 必须刷固定入口页:只刷首页会让 sitemap.xml / rss.xml /
# archives.html 一直发 CDN 里的旧副本(2026-10-01 实测复现)
{ echo "https://usj.cc/";
for p in sitemap.xml rss.xml archives.html posts.html comment.html links.html; do
echo "https://usj.cc/$p"
done
} > /tmp/purge.txt
upx purge --list /tmp/purge.txt \
|| echo "⚠️ purge 失败,降级不影响源站已更新"
- name: 刷新多吉云 CDN
script: |
set -euo pipefail
python3 - <<'PY'
import hmac, hashlib, json, os, urllib.request
AK, SK = os.environ['DOGE_AK'], os.environ['DOGE_SK']
SITE = 'https://usj.cc'
P = '/cdn/refresh/add.json'
urls = [SITE + '/', SITE + '/sitemap.xml', SITE + '/rss.xml',
SITE + '/archives.html', SITE + '/posts.html']
def refresh(rtype, u, label):
body = json.dumps({'rtype': rtype, 'urls': json.dumps(u)})
sig = hmac.new(SK.encode(), (P + '\n' + body).encode(), hashlib.sha1).hexdigest()
req = urllib.request.Request(
'https://api.dogecloud.com' + P, data=body.encode(),
headers={'Authorization': 'TOKEN %s:%s' % (AK, sig),
'Content-Type': 'application/json'}, method='POST')
with urllib.request.urlopen(req, timeout=30) as r:
print('%s -> %s' % (label, r.read().decode()[:200]))
refresh('path', [SITE + '/'], '目录刷新')
refresh('url', urls, '精确刷新 %d 条' % len(urls))
PY
- name: 部署 EdgeOne(境外线路)
script: |
set -euo pipefail
test -n "$EDGEONE_API_TOKEN" || { echo "缺 EDGEONE_API_TOKEN"; exit 1; }
npx edgeone pages deploy ./public -n hugo-blog -t "$EDGEONE_API_TOKEN" --area overseas
- name: 上报部署状态
script: |
set -euo pipefail
H=$(cat .build_hash.out)
curl -s --max-time 20 -X POST \
"https://api.200181.xyz/api/deploy-status?token=${RSS_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"hash\":\"$H\",\"phase\":\"built\"}" || true
curl -s --max-time 20 -X POST \
"https://api.200181.xyz/api/deploy-notify?token=${RSS_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"hash\":\"$H\"}" || true
```
---
## 五、两个必须复刻的细节(`sync.sh` 里的血泪)
### 5.1 purge 不能只刷首页
`sync.sh` 的注释记录了一个实测坑:
> 历史写法只有 `upx purge "$SITE/"`,即只刷首页 —— `sitemap.xml` / `rss.xml` /
> `archives.html` / `posts.html` 这些「老路径」会一直发 CDN 里的旧副本。
> 2026-10-01 实测复现:中转机产物 sitemap.xml 已含新文章(22812B),
> CDN 上那份却仍是 7/24 的 22592B;手动 purge 后立刻变新。
所以骨架里固定刷「首页 + 6 个入口页」。**进阶做法**(完整复刻 `sync.sh` 的精确刷新):
把文件清单存到又拍云 `/__manifest.txt`,每次 CNB 拉下来与本次产物比对,得出「变化 URL」精确刷新。
好处是新增文章时只刷那几页;代价是多维护一个清单文件。**建议先跑固定入口版,观察 1–2 周再决定要不要加。**
### 5.2 又拍云并发参数
`sync.sh` 用 `-w 10 --strong`。当时失败是因为**跨境**(境外 runner 直传),国内直传理论无此问题。
但建议首次仍保守用 `-w 5`(去掉 `--strong`,它会把请求量放大),实测稳定后再往上调。
---
## 六、砍掉清单
| 砍掉 | 说明 |
|---|---|
| act_runner | 不再需要自建 runner |
| Gitea artifact 传递链 | 含 `upload-artifact@v3/v4` 那串坑 |
| 腾讯云 COS | 中转存储不再需要 |
| 广州中转机 `sync.sh` + 1Panel 定时任务 | **注意:只停任务,机器上还有 mysql/openresty 等 7 个容器** |
| `deploy.yml` 的 8 处 `github.server_url` 补丁分支 | `.cnb.yml` 重新写 |
| 两套冗余通知(TG/飞书/邮件三选一) | 独立小优化 |
**发布链路:7 环节 → 3 环节**(写作 → CNB 构建 → 双线上线)
**自维护构建节点:1 台 → 0 台**
---
## 七、前置动作与待核实项
### 必须先确认(按重要性)
1. **★ EdgeOne 从国内上传 372MB 的耗时**。现在部署是从境外 runner 上传(同区域快),改到 CNB(国内)后是跨境上传到 EdgeOne 国际站。**这是本方案最大的未知数**,必须实测一次。
2. **★ CNB 构建节点能否顺畅拉取 `github.com` 的 hugo release**。不行就换国内镜像源,或把 hugo 二进制预置进 CNB 制品库。
3. **又拍云 v0 接口从 CNB 出口的真实表现**。国内直传预期无碍,但需实测确认(这是整个方案的立论基础,务必第一次就验)。
4. 主仓放哪:
- **CNB 当主仓** → 最简,Gitea 可退役,写作后台改 `origin`
- **Gitea 当主仓 + push mirror 到 CNB** → 写作零改动,代价是多留一台机器
5. 多吉云刷新配额(`sync.sh` 里有 200 条截断逻辑,CNB 版本固定入口页不触及)。
### 建议的验证步骤(不动任何现有东西)
1. 建一个 CNB 测试仓库(或就用一个分支),放最小化 `.cnb.yml` + Dockerfile
2. 跑通「hugo build」→ 确认镜像与构建时长
3. 单独测「`upx sync` 到一个测试 bucket」→ 确认国内直传可行
4. 单独测「`edgeone pages deploy` 到测试项目」→ 拿到跨境上传耗时
5. 四项全绿,才动正式仓库
---
## 八、与之前几份方案的关系
| 方案 | 结论 | 与本方案的关系 |
|---|---|---|
| `代码源与构建平台选型.md` | CNB 首选(100GiB 仓库 + 160 核时) | 本方案是它的落地细化 |
| `Gitee方案评估.md` | 可用,但需瘦身 588MB → 500MB | **用 CNB 就不用瘦身** |
| `EdgeOne双区域方案评估.md` | 建议 `-a global` 或双项目 | 双区域照旧,只是构建方换成 CNB |
| `阿里云ESA评估.md` | 2000 文件上限卡死 | 不适用 |
| `精简方案-只留CF和Hugo.md` | 只留 CF 会丢又拍云+多吉云 | **本方案保留双 CDN,复杂度收益同样大** |
**核心区别**:之前几份在讨论「CDN 选谁」;本方案证明**你不需要换 CDN,只需要换掉「构建 + 搬运」那一层**。而这一层恰恰是复杂度的大头。
+47
View File
@@ -0,0 +1,47 @@
# Gitea Secrets 配置清单
> 在浏览器打开 http://23.254.236.47:3001/zqlit/blog/settings/actions/secrets
> 登录账号: zqlit / 密码同服务器
## 核心密钥(必须配置才能发布博客)
| # | Secret 名称 | 值 | 获取方式 |
|---|------------|-----|---------|
| 1 | COS_SECRET_ID | 你的腾讯云 SecretId | 腾讯云 → 访问管理 → API密钥管理 |
| 2 | COS_SECRET_KEY | 你的腾讯云 SecretKey | 同上 |
| 3 | COS_BUCKET | 你的 COS 存储桶名 | 腾讯云 COS 控制台 → 存储桶列表 |
| 4 | COS_REGION | COS 区域如 ap-guangzhou | COS 控制台 → 存储桶列表 |
| 5 | EDGEONE_API_TOKEN | EdgeOne API Token | EdgeOne 控制台 → API管理 |
| 6 | UPYUN_BUCKET | 又拍云服务名 | 又拍云控制台 → 服务管理 |
| 7 | UPYUN_OPERATOR | 又拍云操作员名 | 又拍云控制台 → 操作员管理 |
| 8 | UPYUN_PASSWORD | 又拍云操作员密码 | 又拍云控制台 → 操作员 → 重置密码 |
| 9 | DOGECLOUD_ACCESS_KEY | 多吉云 AccessKey | 多吉云控制台 → API密钥 |
| 10 | DOGECLOUD_SECRET_KEY | 多吉云 SecretKey | 同上 |
| 11 | CDN_URL_LIST | https://usj.cc | ✅ 已配置 |
## 通知密钥(可选,不配则跳过通知)
| # | Secret 名称 | 值 | 获取方式 |
|---|------------|-----|---------|
| 12 | TELEGRAM_BOT_TOKEN | TG Bot Token | @BotFather → /mybots → 选bot → API Token |
| 13 | TELEGRAM_CHAT_ID | 你的 TG ID | 给 @userinfobot 发消息获取 |
| 14 | FEISHU_WEBHOOK_URL | 飞书 Webhook | 飞书群 → 设置 → 群机器人 → 添加 → 复制地址 |
| 15 | MAIL_USERNAME | QQ邮箱 | 你自己知道 |
| 16 | MAIL_PASSWORD | QQ邮箱授权码 | QQ邮箱 → 设置 → 账户 → SMTP授权码 |
## 备份密钥(可选,阿里云OSS周备份)
| # | Secret 名称 | 值 | 获取方式 |
|---|------------|-----|---------|
| 17 | OSS_ACCESS_KEY_ID | 阿里云 AccessKey | 阿里云 RAM → AccessKey管理 |
| 18 | OSS_ACCESS_KEY_SECRET | 阿里云 SecretKey | 同上 |
| 19 | OSS_ENDPOINT | OSS Endpoint | 阿里云 OSS控制台 |
| 20 | OSS_BUCKET | OSS 桶名 | 同上 |
## 配置步骤
1. 浏览器打开 http://23.254.236.47:3001/zqlit/blog/settings/actions/secrets
2. 登录 (zqlit / 服务器密码)
3. 点击 "Add Secret"
4. 逐个添加上面的密钥
5. 核心密钥(1-11)配完后即可测试发布
+248
View File
@@ -0,0 +1,248 @@
# Gitea 迁移指南
> **什么时候看这篇**:自建 Gitea 要换机器 / 换地址时,**从头照着做**。
> 迁移的坑不在「搬数据」,而在**本仓有 9 处地方写死了旧地址**(第三节),漏一处就会在旧机下线那天才报错。
---
## 一、为什么要迁
| 项 | 现状 |
|---|---|
| 承载机器 | **境外 VPS `23.254.236.47`**(2 核 / 2 G 内存 / 50 G 磁盘) |
| **到期时间** | **2026 年 11 月**(见 `deploy/gitea/README.md` 原始记录) |
| 版本 | Gitea **28.0.0**(`/api/v1/version` 实测) |
| 实例地址 | `http://23.254.236.47:3001`(HTTP 直连 :3001,**没有走域名/证书**) |
| 仓库 | `zqlit/blog`(私有) |
| 账号 | `zqlit` |
**它在架构里的角色**:**代码辅仓** —— 只推不拉、不参与构建、不受第三方平台规则约束。
主仓是 CNB(`origin`,推送即触发构建)。
> 换句话说:**它是备份体系里「在线的那一路」**,不是构建链路的一环。
> 这一点决定了迁移比想象中简单得多 —— 见下一节。
---
## 二、★ 迁移范围已经大幅缩小(先读这节,别照旧清单瞎忙)
仓库里的 [`deploy/gitea/迁移前检查清单.md`](../deploy/gitea/迁移前检查清单.md) 是 **2026-10-04** 写的,
当时的目标是「把 **Gitea + act_runner + 又拍云同步** 整套搬到国内机」。
**那个前提已经不存在了**:
| 2026-10-04 的迁移面 | 现在 | 为什么 |
|---|---|---|
| Gitea 本体 | ✅ **仍要迁** | 唯一的代码辅仓 |
| `act_runner`(自建 Actions) | ❌ **不需要** | 构建已由 **CNB** 托管,`.github/` 早已删除 |
| `upyun-sync`(又拍云同步容器) | ❌ **不需要** | 又拍云同步已在 CNB 流水线里 |
| 广州中转机 | ❌ **不需要** | CNB 构建节点在国内,直连又拍云 |
| `cos_sign.py` / COS | ❌ **早已砍掉** | 见归档的 `砍COS改造步骤.md` |
→ **迁移实际只剩一件事:把 Gitea 的数据卷搬到新机器,再把 9 处地址改掉。**
`deploy/gitea/docker-compose.yml` 里那一大坨 `runner` / `upyun-sync` 服务定义,
**迁移时不必启动**(可以直接注释掉,或干脆不管 —— 只 `up -d gitea` 即可)。
---
## 三、★★ 必须同步修改的位置(漏一处 = 旧机下线后才报错)
按「易漏程度」排序。**第 4、5 项是最容易漏的**,因为它们在线上机器上,不在本仓库里。
| # | 位置 | 改成 | 漏掉的后果 |
|---|---|---|---|
| 1 | **本机** `.git/config` 的 `gitea` remote | 新地址 | `git pushall` 推不动辅仓 |
| 2 | `scripts/setup-cnb-remotes.sh` 里的 `GITEA_URL` 默认值 | 新地址 | 重跑脚本时把远端**指回旧机** |
| 3 | `deploy/editor-api/bootstrap.sh` 里的 `GITEA_URL` 默认值 | 新地址 | 新部署的容器指向旧机 |
| 4 | ★ **线上国内机 `/srv/editor-api/.env`** 的 `GITEA_URL` | 新地址 | 写作后台发文章时辅仓推送**持续报错**(主仓成功、只警告,**不会有人发现**) |
| 5 | ★ **线上国内机 `/srv/blog`** 的 `gitea` remote | 新地址 | 同上 |
| 6 | `docs/GITEA_SECRETS.md` | 地址 + 凭据 | 后人照文档操作时指向旧机 |
| 7 | `架构总览.md`(§1.2 地址地图 / §2 旅程图 / §5.2 远端表 / §6 待办 #7) | 新地址 | 文档失真 |
| 8 | `README.md`(发布流程图 + 推送说明 + 脚本表) | 新地址 | 文档失真 |
| 9 | `editor-api/README.md`(辅仓那一行)、`scripts/backup-run.mjs`(头部注释) | 新地址 | 文档失真 |
**不用改的**(它们只引用 remote 名字 `gitea`,不含地址):
`docker-compose.editor.yml`(`PUSH_REMOTES: origin,gitea`)、`editor-api/server.mjs`、
`pushall` 别名本身。
### 一条命令改本机那两处(1 + 2)
```bash
cd /e/GitHub/blog
# 1) 本机 remote —— 换 GITEA_URL 即可,凭据会自动拼进去
GITEA_URL='http://<新机IP>:3001/zqlit/blog.git' \
GITEA_PASS='<Gitea 密码或访问令牌>' \
bash scripts/setup-cnb-remotes.sh https://cnb.cool/zqlit/blog
# 验证:两个远端都在、地址是新的
git remote -v | sed -E 's#://[^@]*@#://***@#'
```
### 线上国内机那两处(4 + 5)
国内机没有 SSH,走 1Panel API 执行(本机 `python .editor-tmp/cnrun.py <脚本>`):
```bash
set -uo pipefail
NEW='http://<新机IP>:3001/zqlit/blog.git'
PASS='<Gitea 密码或访问令牌>'
# ① .env —— 改 GITEA_URL(GITEA_PASS 不变的话不用动)
sed -i "s#^GITEA_URL=.*#GITEA_URL=$NEW#" /srv/editor-api/.env
# ② /srv/blog 的 remote
git -C /srv/blog remote remove gitea
git -C /srv/blog remote add gitea "$(printf '%s' "$NEW" | sed -E "s#^(https?://)#\1zqlit:${PASS}@#")"
# ③ 重建容器(容器启动时读 .env)
cd /srv/editor-api && docker compose up -d --build
# ④ 验证
docker exec editor-api sh -c 'cd /srv/blog && git remote -v' | sed -E 's#://[^@]*@#://***@#'
curl -s http://127.0.0.1:8017/health; echo # remotes 应为 ["origin","gitea"]
```
---
## 四、建议:新实例用域名,不要再用 IP
现在写死的是 `23.254.236.47:3001` —— **IP 一换,上面 9 处全部要改**。
如果新实例挂个域名(如 `gitea.usj.cc`,DNS 指向新机),以后再搬机器**只改 DNS**,上面 9 处全都不用动。
> ⚠️ **Gitea 28 起不再读 `[server] DOMAIN`** —— 实例域名(含默认 SSH 域名)全部来自 **`ROOT_URL`**。
> 改域名时**只改 `ROOT_URL`** 一处。(`deploy/gitea/docker-compose.yml` 里两个都填了,所以现在的配置没问题。)
换域名的额外成本:要在 1Panel 的 OpenResty 里加一个反代站点 → `127.0.0.1:3001`,并配证书。
(目标机 80/443 已被 1Panel 的 OpenResty 占用,Gitea 容器映射的是宿主 `3001`/`2222`。)
**权衡**:花一次配置,换掉以后每一次迁移都要改 9 处的麻烦。**推荐做。**
---
## 五、迁数据
### 5.1 迁移前的硬伤检查(已修,确认一下即可)
| 项 | 状态 |
|---|---|
| `docker-compose.yml` 镜像 tag | ✅ **已修**(2026-10-06):原来是 `gitea/gitea:1.28.0-rootless` —— **这个 tag 不存在**,Gitea 28 起去掉了 `1.` 前缀。现为 `28.0.0-rootless`(pin 死版本,别用 `latest`) |
| `deploy/gitea/.gitignore` 缺 `backups/` | ✅ **已补**:跑一次 `backup.sh` 会在 `backups/` 落 `gitea-dump-*.zip`(含 secrets)与完整 data 卷,原先漏忽略,`git add .` 一下就进历史 |
### 5.2 数据怎么搬 —— **用 rsync,不用 dump**
| 方式 | 适用 | 说明 |
|---|---|---|
| **rsync 数据卷**(推荐) | 同架构迁移 | 保完整(含仓库、app.ini、头像、LFS)。**用 `rsync -a` 保留属主** |
| `gitea dump` | 跨版本 / 跨数据库 | `deploy/gitea/scripts/backup.sh` 已封装;适合做归档,不适合日常迁移 |
```bash
# 旧机 → 新机(在新机上执行,或先把 data 卷打包传过去)
rsync -av --progress root@23.254.236.47:/opt/gitea/data/ /srv/gitea/data/gitea/
```
> ⚠️ **属主必须是 uid/gid 1000** —— Gitea rootless 镜像按 1000 跑,属主不对容器起不来。
> `rsync -a` 会保留;如果中间过了 Windows 或换了 uid,事后要 `chown -R 1000:1000`。
> ⚠️ **新版本必须 ≥ 旧版本**(现在是 28.0.0)。把数据从新版本搬到旧版本会出兼容问题。
### 5.3 启动
```bash
cd deploy/gitea
cp .env.example .env # 填 GITEA_DOMAIN / GITEA_ROOT_URL(用域名的话)
mkdir -p data/gitea
docker compose up -d gitea # 只起 gitea,runner/upyun-sync 不需要
docker compose ps
```
> `deploy/gitea/docker-compose.yml` 里还留着 `runner` / `upyun-sync` 两个服务定义 ——
> 那是 2026-10-04 的形态,**现在不需要**(构建已交给 CNB)。只 `up -d gitea` 即可,
> 或在迁移时顺手把这两段注释掉。
---
## 六、切换顺序(关键:**别让辅仓出现空窗**)
辅仓的价值在于「主仓出问题时它那儿还有」。所以顺序是 **先建好新的、验证通过、再拆旧的**:
```
1. 新机起 Gitea,数据已就位,验证能 clone
2. 本机配好新 remote(第三节 1、2 项)
3. 从本机推一次,确认新实例能收到 ← 新的已经可用
4. 改线上国内机(第三节 4、5 项),重建容器,实测双推 ← 两条推送入口都切过来了
5. 观察一天:`git pushall` 与后台发文章,都确认辅仓同步正常
6. 旧机下线(等到期即可,不必急) ← 旧的才拆
```
**不要在 3、4 步之前就把旧机停掉** —— 那会出现「新的还没验证、旧的已经没了」。
---
## 七、验证清单
```bash
# ① 本机:两个远端都是新地址
git remote -v | sed -E 's#://[^@]*@#://***@#'
# ② 本机实测双推(推临时分支 → 确认 SHA → 删除)
git pushall
git push gitea HEAD:refs/heads/tmp-migrate-test
git ls-remote gitea refs/heads/tmp-migrate-test # 应回新实例的 SHA
git push gitea --delete refs/heads/tmp-migrate-test
git ls-remote gitea refs/heads/tmp-migrate-test # 应为空
# ③ 三处 main 对齐
echo "local = $(git rev-parse HEAD)"
echo "origin = $(git ls-remote origin refs/heads/main | cut -f1)"
echo "gitea = $(git ls-remote gitea refs/heads/main | cut -f1)"
# ④ 历史完整性:新实例上的提交数应与本地一致
git ls-remote gitea refs/heads/main
git rev-list --count HEAD
# ⑤ 线上容器(国内机执行)
curl -s http://127.0.0.1:8017/health # "remotes":["origin","gitea"]
docker exec editor-api sh -c 'cd /srv/blog && GIT_TERMINAL_PROMPT=0 git push gitea --dry-run'
```
> ★ **最后一条特别重要**:`editor-api/src/git.mjs` 对辅仓失败是**只警告不阻断**的 ——
> 辅仓悄悄推不上去,发布照样报成功。所以**必须主动 `--dry-run` 验一次**,别等要用备份时才发现。
---
## 八、回滚
迁移全程是**加法**(新实例起来 → 改地址 → 推一次),旧实例在最后一步之前一直没动:
```bash
# 回滚本机 remote
GITEA_URL='http://23.254.236.47:3001/zqlit/blog.git' \
GITEA_PASS='<旧密码>' bash scripts/setup-cnb-remotes.sh https://cnb.cool/zqlit/blog
# 回滚线上国内机 .env(把 GITEA_URL 改回去)+ docker compose up -d --build
```
**只要没停旧机,回滚就是把地址改回去。**
---
## 九、★ 迁移前先决定:这个辅仓还要不要留
用户提过「感觉架构还是复杂了」。Gitea 是当前**唯一需要你自己运维的常驻服务**(2 核 2 G 境外机),
而它**不参与构建、不参与发布** —— 纯粹是备份体系里「在线的那一路」。
| 选项 | 换来什么 | 代价 |
|---|---|---|
| **A. 迁到新机**(本文档前面全部内容) | 保留「可 clone、可按提交追溯、不受平台规则约束」的在线副本 | 继续养一台机器 + 一处需要运维的实例 |
| **B. 不迁,去掉辅仓** | 少一台机器、少一个要运维的部件;架构回到「CNB 主仓 + F50 离线加密 bundle」两层 | 少一层在线冗余。离线层仍是**完整历史**(`git bundle --all`),且备份已带失败告警 |
| **C. 换成托管平台的私有仓** | 不用自己运维 | CNB 已是托管平台,再挂一家性质相同的,收益有限(见归档的 `平台与选型/` 系列) |
**判断依据**:Gitea 现在挡的是什么?——「CNB 跟 GitHub 一样出问题」。
但离线 bundle 已经覆盖了这个场景(且加密、带告警、每天自动跑)。
Gitea 多出来的独有价值只有两点:**在线可浏览**、**可增量拉取**(不用等一天一次的备份)。
如果这两点你用不上,**选项 B 是合理的简化**。
> 若选 B:把 `deploy/gitea/` 保留在仓库里即可(迁移清单和部署包留着,随时能重建),
> 然后参考本文档第三节,把那 9 处的 `gitea` 相关引用一并清掉
> —— 别忘了 **第 4、5 项**(线上那两处),否则容器会一直对着一个已经没了的辅仓报错。
+68
View File
@@ -0,0 +1,68 @@
# 文档地图
仓库的文档分三层,**看文档先看这张表**:
| 层 | 位置 | 什么时候看 |
|---|---|---|
| **入口** | [`../README.md`](../README.md)、[`../架构总览.md`](../架构总览.md) | 想搞清「这个项目是什么、现在长什么样」 |
| **当前有效** | 本目录(`docs/*.md`) | 要动某块东西,先看对应的专题文档 |
| **历史归档** | [`archive/`](archive/) | 想知道「当初为什么这么选」——**不代表现状** |
> ⚠️ 归档文档开头都有 `📦 本文档已归档` 标记。里面出现的一切结论,**先去 `架构总览.md` 复核再用**。
---
## 一、当前有效
| 文档 | 内容 | 什么时候更新 |
|---|---|---|
| [`证书管家.md`](证书管家.md) | **证书子系统唯一事实源**:现状速览、架构定案(签发在国内机 / Worker 只读)、决策沿革与**勿回退的坑**(§8.4 / §9.9 / §9.11 / §9.12) | 改签发/部署代码前 |
| [`Gitea迁移指南.md`](Gitea迁移指南.md) | 自建 Gitea 要搬机器时,**必须同步改的全部位置** + 数据迁移 + 验证清单 | 迁移 Gitea 时**从头照着做** |
| [`架构精简候选.md`](架构精简候选.md) | **待决策清单**:复杂度盘点 + 5 个可精简候选(收益/代价/风险/怎么做)+ 建议执行顺序 | 想「让架构简单点」时看 |
| [`CNB构建落地方案.md`](CNB构建落地方案.md) | 迁 CNB 的实施方案、流水线细节与实测数据 | 改 `.cnb.yml` / `deploy/Dockerfile` 前 |
| [`GITEA_SECRETS.md`](GITEA_SECRETS.md) | Gitea 相关凭据清单 —— ⚠️ **含明文凭据,勿外传** | 凭据轮换后 |
---
## 二、历史归档(`archive/`)
### 平台与选型 —— 「当初为什么选它 / 没选它」
| 文档 | 结论(一句话) |
|---|---|
| [`代码源与构建平台选型.md`](archive/平台与选型/代码源与构建平台选型.md) | CNB / Gitee / GitLab / EdgeOne / ESA 横向对比 → **选 CNB** |
| [`Gitee方案评估.md`](archive/平台与选型/Gitee方案评估.md) | Gitee 卡单文件 50MB / 单仓 500MB → **未采用** |
| [`EdgeOne双区域方案评估.md`](archive/平台与选型/EdgeOne双区域方案评估.md) | 境内外双线路方案 → 现用于境外线路 |
| [`阿里云ESA评估.md`](archive/平台与选型/阿里云ESA评估.md) | 与 EdgeOne 对比 → **未采用** |
| [`精简方案-只留CF和Hugo.md`](archive/平台与选型/精简方案-只留CF和Hugo.md) | 大精简的设想与边界 |
| [`砍COS改造步骤.md`](archive/平台与选型/砍COS改造步骤.md) | 腾讯云 COS 下线记录(**已执行完毕**) |
### 功能与修复 —— 已完结的一次性改造
| 文档 | 内容 |
|---|---|
| [`诊断报告-2026-10-06.md`](archive/功能与修复/诊断报告-2026-10-06.md) | 四问诊断(当天的体检报告) |
| [`评论孤儿修复方案-2026-10-06.md`](archive/功能与修复/评论孤儿修复方案-2026-10-06.md) | 找回挂在 404 页面上的评论 |
| [`评论加载优化方案.md`](archive/功能与修复/评论加载优化方案.md) | 评论区加载性能(CF 免费版约束下) |
| [`在线编辑器集成评估.md`](archive/功能与修复/在线编辑器集成评估.md) | 在线编辑器集成到后台的评估 → **已实现**(见 `editor-api/`) |
### 主题与内容 —— Hugo 主题 / 字体 / 样式
| 文档 | 内容 |
|---|---|
| [`主题与内容/性能优化(历史)/`](archive/主题与内容/性能优化(历史)/) | 2025 年那轮主题性能优化(18 篇,含 PJAX / JS 按需加载 / 字体子集化)。⚠️ 其中「GitHub Actions」两篇已彻底失效 —— GitHub 已退出本项目 |
| [`主题与内容/古风官职字符添加指南.md`](archive/主题与内容/古风官职字符添加指南.md) | 字体子集增补生僻字的**操作步骤**(配套 `scripts/add_ancient_chars.py`,现在仍可用) |
| [`主题与内容/博客字体优化文章规划.md`](archive/主题与内容/博客字体优化文章规划.md) | 字体优化系列文章的选题规划 |
| [`主题与内容/深色模式表格修复指南.md`](archive/主题与内容/深色模式表格修复指南.md) | 深色模式表格样式问题定位与修复 |
| [`主题与内容/CLEANUP_GUIDE.md`](archive/主题与内容/CLEANUP_GUIDE.md) | Ying 主题自带文档的清理清单(**已执行**) |
---
## 三、还有哪些文档不在本表里
| 位置 | 说明 |
|---|---|
| `blog-admin/README.md`、`blog-admin/部署清单.md` | 评论后端(artalk-cf)的完整说明,**随子系统走** |
| `editor-api/README.md` | 写作后台 API(国内机容器),含「落盘:写一次,存三处」 |
| `write-server/`、`write/` 内的文档 | 各自子系统的说明 |
| `.workbuddy/memory/` | 逐日工作日志与项目长期记忆(**不入库**,仅供本地 agent 使用) |
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 🧹 Ying主题文档清理指南
## 📋 需要删除的文件(20个)
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 博客字体优化方案 - 文章规划
## 📋 文章定位
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 📜 添加古风官职字符到字体子集
## 📋 需要添加的字符
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 01-项目完成总结
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 02-方案1完成总结
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 03-三步优化完整指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 04-JS按需加载优化
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 05-PJAX适配说明
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 06-PJAX修复总结
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 07-字体子集化优化
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 08-GitHub-Actions使用指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 09-Actions修复指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 10-提交指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 11-JS优化测试指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 12-字体优化测试指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 13-主题全面优化分析
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 14-JS优化最终方案
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 15-字体优化手动指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 16-实施总结报告
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# Ying主题 Markdown 表格样式使用指南
**创建日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../../架构总览.md)。
# 📚 性能优化文档索引
**整理日期:** 2026-06-03
@@ -1,3 +1,6 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的工作过程与结论**,可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 深色模式表格样式修复指南
## 🔍 问题诊断
@@ -0,0 +1,179 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 在线编辑文章 · 集成到 api.200181.xyz/admin
> 日期:2026-10-04(定稿)
> 结论:**已实现并本地全链路验证通过。**
> 形态:前端长在现有 `api.200181.xyz/admin` 面板里(新增「文章编辑」tab),
> 后端是一个**零 npm 依赖的轻量 Docker 容器**(`editor-api/`),替代臃肿的 `write-server`。
---
## 0. 一句话
把 `write-server/` 里**只有「文章在线编辑」这一件事**剥出来,做成 200 行级的轻量后端
容器;`/admin` 面板加一个 tab,Worker 只做「登录鉴权 + 注入令牌转发」。
---
## 1. 最终架构
```
浏览器
│ (只用后台已有的管理员登录态,不需要任何新凭据)
▼
Cloudflare Worker api.200181.xyz blog-admin/src/routes/editor.ts
/api/v2/editor/* 只做两件事:
│ ① isAdminRequest() 鉴权
│ X-Editor-Token 只存在这里 ② 注入 X-Editor-Token 反代
▼
nginx post.usj.cc /editor-api/ ──► 127.0.0.1:8017
editor-api 容器(零 npm 依赖)
读写 /srv/blog/content/posts/**,
图片落文章同级目录,
发布 = git add/commit/pull --rebase/push
```
三层各自的职责被切得很干净:
| 层 | 文件 | 职责 | 故意不做的 |
|---|---|---|---|
| 前端 | `blog-admin/public/admin/admin.js`(新增 ~450 行) | 列表 / 编辑 / 图片粘贴 / 保存 / 发布 | 不碰令牌、不直连后端 |
| Worker | `blog-admin/src/routes/editor.ts`(新增,150 行) | 鉴权 + 反代,10 条路由 | 不碰仓库 |
| 后端 | `editor-api/`(新增目录) | 读写 Markdown + git | 不做账号、评论、AI、部署编排 |
---
## 2. 顺带发现的安全问题(write-server 生产环境)
评估过程中实测发现 **`write-server` 线上完全没有鉴权**(只读核实,未做任何写操作):
| 现象 | 证据 |
|---|---|
| 后端公网裸奔 | `23.254.236.47:8016` 直接返回 200 |
| 文章全量泄露 | `https://post.usj.cc/api/posts` 无凭据返回全部 133 篇 |
| `X-Auth-User` 无签名 | 只读明文用户名,可任意伪造 |
| 写操作全裸 | PUT / DELETE / upload / deploy / ai 全部无鉴权、无 middleware |
| nginx 未加 auth_basic | — |
**这正是这次重写的动机**:不是把 write-server 搬个家,而是换成一个「默认安全」的小东西——
令牌只在 Worker 里、端口只绑回环、没配令牌直接拒绝启动。
---
## 3. 改动清单
**新增**
- `editor-api/server.mjs` + `editor-api/src/{frontmatter,posts,git}.mjs` —— 零依赖后端
- `editor-api/test/{frontmatter-roundtrip,save-roundtrip,api-e2e}.mjs` —— 三个测试
- `editor-api/{Dockerfile,README.md}`、`docker-compose.editor.yml`
- `blog-admin/src/routes/editor.ts` —— Worker 反代
- `blog-admin/.dev.vars.example` 的编辑器两项
**修改**
- `blog-admin/src/index.ts` —— 注册 10 条 `/editor/*` 路由
- `blog-admin/src/types.ts` —— Env 加 `EDITOR_API_BASE` / `EDITOR_TOKEN`
- `blog-admin/wrangler.toml` —— `[vars]` 加 `EDITOR_API_BASE`
- `blog-admin/public/admin/admin.js` —— 新增「文章编辑」tab(+约 450 行)
- `blog-admin/public/admin/admin.css` —— 编辑器样式(+约 80 行)
- `.gitignore` —— 加 `.editor-tmp/` / `.editor-trash/`
---
## 4. 测试结论(全部通过)
| 测试 | 结果 | 说明 |
|---|---|---|
| `frontmatter-roundtrip.mjs` | **130/130** | split/join 逐字节还原 + parse/stringify 深相等 |
| `save-roundtrip.mjs` | **130/130** | 每篇「读出来原样存回去」sha256 不变,测完全部还原 |
| `api-e2e.mjs` | **44/44** | 鉴权/读取/回存/只改正文/改字段/新建删除/上传/git/撞名/无空行 |
| Worker 反代链路(`.editor-tmp/verify-relay.mjs`) | **26/26** | 真实走 wrangler dev,含鉴权 403 / 404 / 409 |
| 后台 UI(无头 Chrome 真点) | **全通过** | 登录→列表→打开→改→保存→发布弹层,4 张截图 |
**过程中抓到并修掉的真 bug:**
1. **front matter 后的空行**:语料里 111 篇有空行、19 篇没有;`getPost` 把前导空行剥掉后
信息丢了,`savePost` 又无条件补一个 → 那 19 篇一保存就被平白多插一个空行。
修法:`readIndex` 记 `bodyLead`,回写时按原文件风格还原。
2. **slug 撞名静默改错文件**(详见第 6 节):定位键从 slug 换成目录名。
3. CRLF/LF、块标量 chomping(`|-` / `|` / `|+`)、`getPost` 漏 `filePath`/`eol`、
重复 slug 返回 500 —— 都是早期测试抓到的。
---
## 5. 上线步骤
### ① 服务器侧(23.254.236.47)
```bash
# 1. 博客仓库 checkout 到 /srv/blog(main 分支,git 工作区)
# (原 write-server 用的那份仓库可以直接沿用,确认 remote 是 CNB + GitHub 两段)
# 2. 放 compose 与令牌
cd /srv/blog
mkdir -p /srv/editor-trash
cat > .env <<'EOF'
EDITOR_TOKEN=<openssl rand -hex 32 生成的值>
EOF
docker compose -f docker-compose.editor.yml up -d --build
curl -s http://127.0.0.1:8017/health # 应返回 {"ok":true,...}
```
### ② nginx(post.usj.cc 的 server 块里加一段)
```nginx
# —— 文章编辑后端:只给 Cloudflare Worker 反代用 ——
# 令牌本身就是鉴权(X-Editor-Token),所以这里不再叠 auth_basic。
location /editor-api/ {
proxy_pass http://127.0.0.1:8017/; # 末尾的 / 会剥掉 /editor-api 前缀
proxy_http_version 1.1;
proxy_set_header Host $host;
client_max_body_size 25m; # 图片直传
proxy_read_timeout 120s; # git push 可能慢
}
```
> 可选加固:`location` 里再叠 `allow <Cloudflare IP 段>; deny all;`,
> 让这条路径只有 CF 能碰到。令牌泄露才是真风险,这层属于纵深防御。
### ③ Cloudflare 侧
```bash
cd blog-admin
npx wrangler secret put EDITOR_TOKEN # 粘贴与服务器 .env 相同的值
npx wrangler deploy
```
`EDITOR_API_BASE` 已写在 `wrangler.toml` 的 `[vars]`(`https://post.usj.cc/editor-api`)。
### ④ 验证
打开 `https://api.200181.xyz/admin` → 「内容管理 / 文章编辑」→ 随便开一篇 → 改一个字 →
保存 → 「发布 / 同步」里确认文件出现在待发布列表。
### ⑤ 下线 write-server(**确认新编辑器好用之后再做**)
1. nginx 里摘掉 write-server 的 `location /`(先只留 `location /editor-api/`)
2. `docker stop write-server && docker update --restart=no write-server`
3. 关掉 `8016` 的公网映射(compose 里删 ports 或改绑 127.0.0.1)
4. 观察几天没问题,再删镜像与数据卷(**删前先备份**)
---
## 6. 遗留问题:slug 撞名(需要你决定)
仓库里有 **5 组**文章共用同一个 slug,Hugo 的 permalink 是 `/:slug`,所以每组里
**有一篇在线上是被另一篇覆盖掉的(打不开)**:
| slug | 两篇 |
|---|---|
| `20210901` | Twitter主题加入加载耗时… / 无悔 |
| `20211122` | 情侣恋爱倒计时小工具… / 这组照片的主题,咱就叫它光吧 |
| `20211128` | 大学生体测… / 可惜不能一直做小孩子… |
| `20211223` | 更换掉jsdelivr… / 放假之前最后一次的照片合集… |
| `20240602` | parsec远程软件报6023错误 / idea关闭ai自动补全 |
编辑器已经把这件事**标出来了**(列表里黄色「URL 冲突」标签;拿撞名 slug 去查会返回 409
并列出候选篇目,不会猜)。但**改哪一篇的 slug、还是让后写的那篇换个 slug**,需要你定。
> 顺带:改 slug = 改网址,旧链接会 404。如果在意 SEO,得配套做重定向。
@@ -0,0 +1,284 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 评论加载优化方案(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
<link rel="preconnect" href="https://cravatar.cn">
<link rel="preconnect" href="https://api.200181.xyz" crossorigin>
<link rel="dns-prefetch" href="https://api.200181.xyz">
```
**估算收益**:首屏第一跳省下 ~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` 之前。`</` 会被替换成 `<\/` 防 `</script>` 逃逸。
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:<site>:<page>` 这个 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
<script async src="https://019e3dec-3312-7873-862a-3f56ac99ea83.spst2.com/ustat.js"></script>
```
它不在主题自身模块里,是页面**唯一的外部脚本**,会把访问数据上报给第三方。
**确认是不是你自己加的**;若不是,建议连同这一行一起删掉。
**③ ✅ 已修:非评论类错误层会让整个评论区「假故障」一次(顿一下 + 弹「已恢复」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。
@@ -0,0 +1,124 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 评论区孤儿评论修复方案(2026-10-06)
> ## ✅ 状态:**已执行完成**(2026-10-06 12:2x)
>
> - 21 条 UPDATE 全部成功,**816 条评论已迁回正确页面**
> - 22 个目标 key 条数精确到位;残留老 key 已清空
> - 端到端验证通过:`/20230424.html`(苏州一日游)已能显示评论
> - 备份:`.editor-tmp/BACKUP_816条_20261006.txt`
> - 未迁移的 13 个 key / 377 条保持原位未动
## 一句话结论
**你的评论一条都没丢。** 线上 D1 里有 **3423 条评论**,
其中 **1193 条**因为老站 URL「日期错了一天」变成了孤儿(挂在 404 页面上),
**816 条**可以精确找回,**377 条**因现站已无对应文章而保留原样。
---
## 一、线上库真实基线
```
comments 表:3423 条(存活 3380 条)
id 范围:10393 ~ 15674
94 个 page_key:
· /comment.html 留言板 1001 条
· 65 个「老站日期型」key(/20230423.html) 1975 条
· 28 个「新站时间戳型」key(/20260708110221.html) 404 条
```
**你举例的 `/20230424.html`(苏州一日游)有 40 条评论 —— 找到了,挂在 `/20230423.html` 上。**
---
## 二、问题成因
老站迁移时,**部分文章的 URL 日期错位了一天**:
| | 错误 URL(有评论) | 正确 URL(无评论) |
|---|---|---|
| 苏州一日游 | `/20230423.html`(40 条,title 显示 "404 Page not found") | `/20230424.html`(页面正常,0 条) |
| 新的开始 | `/20230527.html`(76 条,title=404) | `/20230528.html`(页面正常,0 条) |
- 评论**绑在**老 URL 上
- 页面**只认**新 URL(老 URL 返回 302 → /404.html)
- 所以评论「有数据但显示不出来」
---
## 三、修复方案(待批准执行)
**23 个孤儿 key / 816 条** → 改挂到现站正确 slug。
`UPDATE comments SET page_key='/新key' WHERE page_key='/老key'`(**按 page_key 定位,不用 id**)
| 孤儿 key | 条数 | → 目标 | 文章标题 |
|---|---|---|---|
| `/20230527.html` | 76 | `/20230528.html` | 新的开始 |
| `/20220810.html` | 58 | `/20220811.html` | typecho实现QQ头像用户评论加密 |
| `/20220906.html` | 57 | `/20220907.html` | 主力机由荣耀20切换到iPhone13 |
| `/20221102.html` | 52 | `/20221103.html` | 毕业篇:最后几个月的大学生活 |
| `/20221203.html` | 50 | `/20221204.html` | 2022·襄阳第一场雪 |
| `/20220227.html` | 48 | `/20220228.html` | 2022,除夕过后的那些事 |
| `/20211121.html` | 46 | `/20211122.html` | 这组照片的主题,咱就叫它光吧 |
| `/20220430.html` | 40 | `/20220501.html` | iphone快捷指令发布动态说说(合并) |
| **`/20230423.html`** | **40** | **`/20230424.html`** | **苏州一日游 ★** |
| `/20220503.html` | 34 | `/20220501.html` | iphone快捷指令发布动态说说(合并) |
| `/20211222.html` | 33 | `/20211223.html` | 放假之前最后一次的照片合集 |
| `/20230803.html` | 32 | `/20230804.html` | 家乡随拍 |
| `/20230125.html` | 31 | `/20230126.html` | 感谢哥哥给的网站 |
| `/20220619.html` | 30 | `/20220620.html` | 我跳绳的那些日子 |
| `/20221016.html` | 28 | `/20221017.html` | 毕业纪念篇:图书馆 |
| `/20220104.html` | 26 | `/20220102.html` | 祝大家元旦快乐 |
| `/20211113.html` | 24 | `/20211114.html` | 双十一已经变味了 |
| `/20230409.html` | 22 | `/20230410.html` | 四月随笔 |
| `/20211210.html` | 21 | `/20211211.html` | 盘点注册的域名 |
| `/20211007.html` | 19 | `/20211008.html` | 记录人生第一次洗牙 |
| `/20210911.html` | 19 | `/20210912.html` | 别让抖音支配了你的大学生活 |
| `/20221213.html` | 16 | `/20221214.html` | Apple Watch Series7 体验 |
| `/20230716.html` | 14 | `/20230717.html` | 襄阳唐城 |
✅ **已核实:18 个目标页当前评论数全部为 0** —— 不会覆盖、不会冲突。
---
## 四、不迁移的(377 条,保留原样)
现站已无对应文章,评论**保留在原位不删**(万一以后想恢复旧文章,评论还在):
| 孤儿 key | 条数 | 内容主题 |
|---|---|---|
| `/20220115.html` | 54 | 开发 app / uniapp |
| `/20220410.html` | 51 | iPhone 更新 |
| `/20211117.html` | 47 | Twitter 主题界面 |
| `/20211111.html` | 43 | 网站头像插件 |
| `/20211203.html` | 32 | 人像摄影 |
| `/20220328.html` | 32 | 青年大学习 |
| `/20211124.html` | 30 | RSS 阅读器(蚁阅) |
| `/20260724101237.html` | 24 | 流量卡/广电(现站 slug 冲突) |
| `/20211024.html` | 20 | 医疗/手术 |
| `/20211017.html` | 17 | 弟弟听力/同济医院 |
| `/20211015.html` | 14 | 恋爱话题 |
| `/20220607.html` | 11 | 腾讯游戏/王者 |
| `/202610011226.html` | 2 | Gitea 测试文 |
---
## 五、执行方式
```bash
# 1. 备份(先把这 816 条 dump 出来)
# 2. 执行 21 条 UPDATE(按 page_key 定位)
# 3. 核对:目标 slug 评论数 = 预期值;总量仍为 3423
```
SQL 在 `.editor-tmp/FINAL_SQL.json`。
---
## 六、顺带修掉的两个坑
1. `scripts/refresh_cdn.js` —— 多吉云 `rtype=path` 目录刷新**必须带尾斜杠**(已加兜底 + 日志)
2. `blog-admin/src/routes/rss/tools.ts` —— 加 `s-maxage` / `CDN-Cache-Control`,让 CF 边缘真正缓存
@@ -0,0 +1,335 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 四问诊断报告(2026-10-06)
一次性回答四个问题:CDN 刷新、KV 配额、D1 评论对账、certimate 迁移。
---
## 一、多吉云刷新 & 已删文章还能访问
### 1.1 先更正我自己的话
我一开始说「多吉云那条已经是全量(`--all`)」——**这句话是错的**,实测证据如下:
```
cnb-secrets.yml:33 CDN_URL_LIST: "https://usj.cc"
```
`scripts/refresh_cdn.js` 只做一件确定性的事:把 `CDN_URL_LIST` 按逗号/换行拆开,
逐条调 `/cdn/refresh/add.json`(`rtype: "path"`)。**当前配置里这个变量只有首页一个 URL**。
结论:**多吉云确实不是全量刷新,只刷新了首页 `https://usj.cc`。**
### 1.2 那为什么删掉的文章还能访问?
不是多吉云的问题。实测这条 URL:
```
$ curl -sI https://usj.cc/202610021614.html
HTTP/1.1 200 OK
Server: marco/3.2 ← 又拍云特征头
X-Cache-Lookup: Hit From Upstream Cluster ← 命中缓存
Last-Modified: Sun, 04 Oct 2026 14:12:26 GMT ← 旧副本时间
Cache-Control: max-age=691200 ← 8 天 TTL
Age: 131684 ← 已缓存约 1.5 天
```
判别实验(关键):
| URL | 状态 | 说明 |
|---|---|---|
| `/202610021614.html`(已删除) | **200** | 缓存里还有旧副本 |
| `/zzz-not-exist-111.html`(从未存在) | **302 → /404.html** | 源站没这个文件 |
两者行为不同 ⇒ 说明**源站确实已经没有这个文件了**(本地 `content/` 和 `public/` 均已确认无此页),
现在返回 200 完全是**又拍云上的 8 天旧副本**。
### 1.3 根因:两条 CDN 线路都刷不到「已删除页」
`scripts/purge_list.js` 的逻辑是:遍历 **构建产物 `public/`**,把里面所有 `.html` 列出来提交刷新。
```js
// 全量时 walkHtml('public') —— 遍历构建产物
// 已删除的页面自然不在里面 → 永远不会被提交刷新
```
也就是说:**刷新清单只会包含「现在存在的页面」**。一个页面被删掉后,
它就从清单里消失了,于是 CDN 上的旧副本只能等 TTL 自然过期。
- 又拍云:HTML `max-age=691200` = **8 天**
- 多吉云:只刷首页,问题更明显
### 1.4 处置建议
| 方案 | 说明 | 成本 |
|---|---|---|
| **A. 什么都不做** | 等 8 天自然过期 | 0 |
| **B. 手动刷新一次**(推荐) | 把已知的已删 URL 提一次刷新,立刻生效 | 一次性 |
| C. 流水线加「删除清单」 | 构建时对比上一次产物,把消失的 `.html` 追加进 purge 清单 | 改脚本 |
对已删文章这种低频事件,**B 足够**。需要的话我可以立刻把已知的那几个已删 URL 提交到又拍云+多吉云。
---
## 二、KV 配额告警:需要调整吗?
### 2.1 告警内容
> 已使用 Workers KV 免费套餐每日限制的 **50%**,超限将返回 429;重置时间 2026-10-06 00:00 UTC。
> 免费额度:读 100,000/天、写 1,000/天、删 1,000/天、list 1,000/天。
### 2.2 谁在吃配额?(实测 4 类来源)
| 来源 | 代码位置 | 频率 | 影响 |
|---|---|---|---|
| **评论数缓存** | `comments.ts:180/206` | 每次评论列表请求,miss 时写(TTL 600s) | 读 + 写 |
| **评论数版本号** | `comments.ts:53/61` | 评论增删时写 | 写 |
| **friend-link favicon** | `routes/rss/tools.ts:86` | **每次访问友链/友圈页,每个友链一次** | **读(大户)** |
| 人机验证 / 通知节流 | `human.ts` / `admin-notify.ts` | 低 | 少量 |
**读数最大的就是 favicon 接口。** 友链共 54 条(可见 42 条),其中 15 条头像走 `/api/favicon`:
```js
// circles.html / linkify.js
img.src = RSS_API_BASE + '/api/favicon?url=' + encodeURIComponent(target);
```
每刷一次友圈/友链页 → 触发 N 次 `/api/favicon` → 每次至少 1 次 KV `get`。
favicon 本体有 24h 浏览器缓存,但**首访、清缓存、换设备都会重新打**。
### 2.3 建议:**暂时不用调整,但建议做一个小优化**
- **读 50% 是「接近」不是「超了」**,且每天 UTC 0 点清零。按当前站点的访问量,正常不会打穿。
- 即使打穿,后果是 `/api/favicon` 走兜底字母图、评论数退化为实时查库——**不是灾难性故障**。
- 免费版写限额 1,000/天,比读更容易被评论数缓存写穿。真正的风险点是**评论突然暴涨**或**有人刷评论**。
**可做的小优化(成本低、收益直接):**
1. favicon 接口响应已经带了 `Cache-Control: max-age=86400`,可以加一层 **Cloudflare Cache Rule**,
让 `/api/favicon*` 在边缘缓存 24h,这样 KV 读基本归零。
2. 若担心写限额,把 `cml:ver:` 的写入合并/延迟(比如 5 分钟内同页重复 bump 只写一次)。
**要不要升级到付费($5/月)?** 目前**不需要**。等到真收到「已超限」而不是「接近」的邮件再说。
---
## 三、D1 评论对账:老文章评论为什么没了
### 3.1 先说结论(这个和你想的不一样)
原库 **3979 条**评论 / 线上 **3423 条**。乍看差 556 条,但拆开看,**「缺失」的绝大部分根本不是评论**:
| 项 | 数量 |
|---|---|
| 线上缺失合计 | **711 条 / 63 页** |
| 其中 **点赞型记录**(内容含 `[LIKE]`) | **494 条(69%)** |
| → **真实评论型缺失** | **217 条** |
**关键发现:线上 `[LIKE]` 记录为 0 条。**
```
$ SELECT SUM(CASE WHEN content LIKE '%[LIKE]%' THEN 1 ELSE 0 END) FROM comments;
→ 0
```
说明当年数据导入时,**已经主动过滤掉了所有点赞型记录**——这是正确的设计(点赞不该占用评论表)。
所以拿原库和线上直接比条数,本身就会假性多出 494 条。
### 3.2 缺失的 217 条真实评论,按站点拆
| 站 | 缺失 | 其中点赞 | 真实 |
|---|---|---|---|
| 伍比贰 | 521 | 431 | **90** |
| 优世界 | 101 | 0 | **101** |
| 演示站 | 48 | 41 | 7 |
| 往后 | 41 | 22 | 19 |
**注意:「伍比贰」「演示站」「往后」是另外的站点,不属于 usj.cc。** 它们的数据本来就不该进本站。
### 3.3 「优世界」那 101 条对得上现站吗?
逐页核对,结果是 **一页都对不上**:
| page_key | 条数 | 现站情况 |
|---|---|---|
| `/yl` | 48 | 页面不存在(是 2022 年的旧友链页) |
| `/20241018-some-useful-win-tools` | 12 | 文章还在,但 slug 已改为 `20241019`,且**是草稿**(未发布) |
| `/27`、`/13`、`/m/17` 等 | 41 | 不存在(旧站的分页/短链) |
| `/20240921-clock-in` | 5 | 文章已删除 |
**根因不是「key 对不上」,而是这些文章本身已经不存在了。**
评论是挂在「已删除文章」上的,key 再怎么规范化也接不回来。
### 3.4 站上现存页面 vs 线上 —— 有没有真的错位?
**没有。** 用现站 284 个 slug 逐一匹配线上缺失的 63 个 key:
```
能对上现存页面: 1 页 / 4 条 (只有 /about.html)
对不上 : 62 页 / 707 条
```
**唯一一个疑似错位:`/about.html`(4 条)**——但深挖后发现**它也不需要修**:
```
id=14229 /about.html [伍比贰] 2026-02-07 👍 已点赞 Cool [LIKE]
id=14268 /about.html [伍比贰] 2026-02-12 👍 已点赞 Interesting [LIKE]
id=14269 /about.html [伍比贰] 2026-02-12 👍 已点赞 很棒的文章! [LIKE]
id=14276 /about.html [伍比贰] 2026-02-13 👍 已点赞 写得很好 [LIKE]
```
这 4 条全是**点赞型记录**,而且属于**「伍比贰」站**,本就不该进本站。
→ **结论:线上 D1 没有任何错位,完全不需要修复。**
### 3.5 线上「多出」的 7 页是什么?
| page_key | 条数 |
|---|---|
| `/20260630162329.html` | 25 |
| `/20260724101237.html` | 24 |
| `/20211001.html` | 14 |
| `/20261005113439.html` | 13 |
| `/20260629223659.html` | 10 |
| `/202610011226.html` | 2 |
| `/20261005213010.html` | 2 |
这些是**导出 db 之后新增的评论**(2026-06 之后),属于正常增长,不用动。
### 3.6 修复建议
**结论:线上 D1 不需要做任何修复。**
你的直觉「很多评论没跟页面 key 对应上、老文章没评论了」——
真实情况是:**那些评论对应的文章,本身已经不在了**(已删除 / 仍是草稿 / 属于别的站),
key 再怎么规范化也接不回来。线上数据是**干净的**。
**不建议做的**:
- ❌ 批量把「伍比贰/演示站/往后」的评论导进来 —— 那是别的站的数据(含大量点赞记录)
- ❌ 把 `/yl`、`/27` 等改成别的 key —— 没有正确的目标,改了反而污染
- ❌ 恢复 494 条点赞记录 —— 线上设计本就不存点赞(`[LIKE]` 记录当前为 0)
**如果你更看重「把老评论展示出来」**,唯一的正路是**重建这些页面**
(把已删文章从 git 历史恢复,或用 slug 重定向)。这个要单独评估,工作量比改 key 大得多。
**但请注意**:即使恢复了页面,能救回的也只是极小一部分——
「优世界」站真实缺失的 101 条里,48 条属于 `/yl`(2022 年旧友链页),
41 条属于 `/27` `/13` `/m/17` 等旧站分页短链,只有 12 条是正经文章评论。
### 3.7 那「评论少了」的体感从哪来?
很可能来自这两点(都正常):
1. **点赞不再算评论**:原库 494 条点赞在导入时被过滤,前端看到的评论数自然变少。
2. **老文章页面已删**:文章没了,评论区自然也没了。
如果确实想提升「评论数」,更实际的做法是**在新文章上做引导**,而不是抢救旧数据。
---
## 四、certimate 能否迁到 Cloudflare
### 4.1 你现在的架构
```
certimate(跑在某台服务器上)
├─ 申请:litessl CA,*.usj.cc + usj.cc,DNS-01,tencentcloud-dns
├─ 部署:dogecloud-cdn(多吉云 CDN)
└─ 部署:1panel website(那台服务器上的站点)
定时:10 12 * * *(每天 UTC 12:10)
失败邮件:177018615@qq.com
```
当前线上证书实测:
```
issuer = LiteSSL RSA CA 2025(TrustAsia)
subject = CN=usj.cc
SAN = usj.cc, *.usj.cc
有效期 = 2026-09-08 → 2026-12-07
```
### 4.2 certimate 支持 Cloudflare 吗?
**支持,而且支持两种角色:**
| 角色 | 支持 | 说明 |
|---|---|---|
| **DNS provider(申请用)** | ✅ | 填 Cloudflare API Token 即可,用于 DNS-01 验证 |
| **Deploy provider(部署用)** | ✅ | 可将证书部署到 Cloudflare |
(certimate 官方:60+ DNS 托管商、120+ 部署目标,Cloudflare 在列。)
### 4.3 你的真实目标:「把服务器干掉」
需要先厘清一件事:**usj.cc 现在到底谁在提供 HTTPS?**
从实测响应头看:
```
Server: marco/3.2 ← 又拍云
Via: T.206.M, V.403-zj-fud-202, ... ← 又拍云多级节点
```
**对外服务的是又拍云 CDN**,不是那台服务器。那台服务器承担的是:
- 跑 certimate(证书申请+分发)
- 1Panel 上的 write-server / editor-api 等自建服务
所以「干掉服务器」要分清两件事:
**① 只干掉「证书维护」这件事** → **完全可以,用 Cloudflare 托管 DNS + certimate 的 CF provider。**
路径:
1. 把 `usj.cc` 的 **DNS 托管迁到 Cloudflare**(当前在腾讯云 DNSPod)
2. certimate 里把「DNS 提供商」从 `tencentcloud-dns` 换成 `cloudflare`(填 CF API Token)
3. 证书申请照旧(litessl 或换 Let's Encrypt),DNS-01 走 CF API
4. 部署目标改为「Cloudflare」(如果把站点也迁到 CF)或保留又拍云/多吉云
**② 连「站点托管」也迁到 Cloudflare** → **可行但有取舍:**
| 优点 | 缺点 |
|---|---|
| CF 免费版自带 Universal SSL(免申请免续期) | 国内访问速度**不如又拍云/多吉云**(CF 免费版节点在境外) |
| 不用再管 90 天续期 | 免费版 Universal SSL 只覆盖 `example.com` + `*.example.com` |
| 有 CF Origin CA(15 年有效期)给回源用 | 想用 CF 自签源站证书需另配 |
### 4.4 ⚠️ 关键提醒:国内访问速度
你的读者主要在国内。**又拍云/多吉云是国内 CDN,Cloudflare 免费版是境外节点**——
如果为了省一台服务器而把整个站点切到 CF,**国内访问大概率变慢**(首包延迟从几十 ms 变成几百 ms)。
**所以我建议:**
- ✅ **可以做的**:把 `usj.cc` 的 DNS 托管迁到 Cloudflare(CF DNS 免费、管理方便、API 完善),
certimate 用它做 DNS-01,证书照旧签发后部署到**又拍云 + 多吉云**。这样证书维护不再依赖服务器上的 1Panel 部署环节。
- ⚠️ **建议保留又拍云/多吉云做 CDN**,不要为了「干掉服务器」把站点也搬到 CF。
- ❌ 如果那台服务器还跑着 write-server / editor-api,**那就不能关**——关之前先确认没有别的服务在跑。
### 4.5 你现在到底能不能关服务器?
需要先回答这个问题:**那台服务器上除了 certimate,还跑着什么?**
- 如果只有 certimate → 迁完就能关 ✅
- 如果还有 write-server / editor-api(按记忆应该有)→ **关不掉** ⚠️
建议你在关之前先列一下那台机器上的服务清单。需要的话我可以帮你连上去盘一遍。
---
## 附:本次用到的排查命令
```bash
# CDN 缓存判别
curl -sI https://usj.cc/202610021614.html | grep -iE "server|age|last-modified|x-cache"
# D1 查询(curl 走 IPv4,Node fetch 会 ENETUNREACH)
curl -s -X POST \
"https://api.cloudflare.com/client/v4/accounts/$ACC/d1/database/$DB/query" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"sql":"SELECT page_key, COUNT(*) FROM comments GROUP BY page_key"}'
# 线上是否有点赞记录
# → SELECT SUM(CASE WHEN content LIKE '%[LIKE]%' THEN 1 ELSE 0 END) FROM comments; → 0
```
@@ -0,0 +1,237 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# EdgeOne 双区域方案评估
> 2026-10-04 · 针对「境外用 EdgeOne 国际站、境内改用 EdgeOne 国内站」的构想
> 以及「write-server 拆成后端服务 + 前端与 blog-admin/Worker 合并」的可行性
## 一、结论先说
**方案成立,而且比 Cloudflare 方案好。** 三个理由:
1. **不违反任何服务条款** —— CF 要加速国内只能走「优选 IP」那条明文违规的路,EdgeOne 是正规产品能力。
2. **国内速度是量级差异** —— 备案域名的 EdgeOne 大陆节点实测 **50–90ms**;你现在 CF 后端实测 **750ms**(差 8–10 倍)。
3. **你已经在用了** —— `deploy-edgeone` job 现在就部署在这上面(`edgeone pages deploy --area overseas`),迁移成本接近零。
**而且我发现了你可能没注意到的一件事**:EdgeOne Pages 有个 `--area` 参数,你现在用的是 `--area overseas`(**主动排除了中国大陆**)。这可能意味着你**只要改一个参数**就能达到目的,而不必新建一个项目。详见第三章。
---
## 二、你的备案状态(关键前提,已核实)
- 站点页脚:**鄂ICP备2022015735号-1**
- 官方文档明确:*当项目的加速区域为「中国大陆可用区」或「全球可用区(含中国大陆)」时,添加的域名必须完成 ICP 备案*
- **你的域名已备案 → 具备启用大陆节点的资格** ✅
这一条是整个方案成立的地基。反过来说,如果没备案,这条路直接堵死(只能用海外节点,国内 670–1400ms)。
---
## 三、★ 关键发现:`--area` 参数
EdgeOne Pages 的 CLI 部署命令带一个区域参数:
| 参数 | 含义 |
|---|---|
| `--area overseas` | 只覆盖境外,**不含中国大陆** ← **你现在用的就是这个** |
| `-a global` | 覆盖全球 **+ 中国大陆**(需备案域名绑自定义域名) |
也就是说,你现在这个 `hugo-blog` 项目**是刻意把大陆排除在外的**。于是有两条路:
### 路 1(最省):单项目改参数
把 CI 里那一行改成 `-a global`,一个项目、一套配置、一个 token,同时覆盖境内外。
- 好处:**零新增组件**,改动只有一个参数
- 风险:**境外质量可能下降**。有多位站长实测反馈,global 模式下「海外就差点意思,只有一个节点,走 Anycast 网络」——这可能正是你当初选 `overseas` 的原因
- **必须实测**:改完之后用境外节点测一次,别只看国内变快了
### 路 2(你提的):双项目
- **国际站项目**(`console.intl.cloud.tencent.com`,免备案)→ `--area overseas`,保境外质量
- **国内站项目**(`console.cloud.tencent.com`,需实名+备案)→ `-a global`,保大陆速度
- 两个控制台、两套 token、两次部署
> ⚠️ **一个待实测的疑点**:官方文档里,国际站的 Pages 与国内站的 Pages 对「大陆节点」的支持描述**互相矛盾**(国际站文档说 `-a global` 含大陆,产品介绍又说国际站不含国内节点)。**结论不明,必须自己测一次**。测法见第七章。
---
## 四、EdgeOne Pages 免费额度(官方定价页)
| 项目 | 免费额度 | 你的用量 | 够吗 |
|---|---|---|---|
| 安全加速流量 | **不限** | — | ✅ |
| 安全加速请求数 | **不限** | — | ✅ |
| 构建次数 | **500 次/月** | 约 60–90 次 | ✅ |
| Edge Functions 请求 | 300 万/月 | — | ✅ |
| Cloud Functions 请求 | 100 万/月 | — | ✅ |
| KV 存储 | 1 GB | — | ✅ |
| 站点数 | 1 个主域名 + 200 子域名 | — | ✅ |
| 超出用量 | **收费版推出前不中断服务** | — | ✅ |
**唯一的硬限制(重要)**:
> **单文件、单线程限速 4 Mbps(约 500 KB/s)** —— 多位站长实测确认,官方未披露。
要正确理解它:
- 是**单文件单线程**限速,**不是整站带宽限速**。并发请求互不影响
- 你的 `zql-v2.woff2` 字体 1.2 MB → 首次加载约 **2.4 秒**(之后走缓存,不再走网络)
- 那 7 个 mp4 里最大的 12.8 MB → 首次加载约 **25 秒**(这是这个方案最实际的痛点)
- 腾讯云自家 CDN 也有这个限制,属地是「免费套餐的取舍」,不是 bug
**如果视频是核心内容**,这一条要认真权衡;如果只是少量点缀,影响可控。
---
## 五、能砍掉什么
对比**现在的架构**(EdgeOne 国际 + 又拍云 + 多吉云 + 广州中转机 + sync.sh 轮询):
| 组件 | 现架构 | EdgeOne 双区域 |
|---|---|---|
| 境外 CDN | EdgeOne 国际站 | EdgeOne 国际站(不变) |
| 境内 CDN | 又拍云 + 多吉云 | **EdgeOne 国内站** |
| 产物搬运 | 广州中转机 + `sync.sh` 每分钟轮询 | **删除** |
| 产物中转 | 腾讯云 COS | 已砍(进行中) |
| 凭据面 | 又拍云密码 + 多吉云 SK(明文写在 sync.sh) | **删除** |
| 厂商数 | EdgeOne + 又拍云 + 多吉云 + 腾讯云 COS | **腾讯云一家** |
**净收益**:少 2 家厂商、少 1 台常驻机器、少 1 个每分钟跑的轮询脚本、少 2 套硬编码凭据。
**发布链路从 7 环节降到 4 环节**(写作 → 构建 → 双区域部署 → 通知)。
---
## 六、write-server 拆分评估(你问的第二件事)
### 6.1 结论:技术可行,而且**必须拆**才能上边缘平台
这不是"能不能"的问题,而是**只能这样**。EdgeOne Pages(和 CF Workers 一样)的运行时限制是硬的:
- **无持久化文件系统** —— 无法读写 `content/` 下的 `.md`
- **不能执行 git** —— 无法 `git pull/push`
- 单次函数执行有超时(通常 30s)
而 `write-server` 的核心恰恰是这两件事:**读写本地 md 文件 + git 操作**。
### 6.2 现状的耦合点(已扫描)
```
write-server/src/
app/{edit,login,posts,images,links,feeds,comments,users,bot,wechat}/ ← 页面(可上前端)
app/api/ ← 23 个 API 路由
lib/
posts.ts ← fs 读写 md ⛔ 必须留后端
git.ts ← child_process git ⛔ 必须留后端
hugo.ts ← 调 hugo 二进制 ⛔ 必须留后端
recycle.ts ← fs ⛔ 必须留后端
auth.ts ← fs(用户表) ⛔ 必须留后端
bot/* ← 子进程轮询 ⛔ 必须留后端
ai.ts / artalk.ts / rssapi.ts / wechat.ts / fetch.ts ← 纯 HTTP(可两边)
```
**好消息**:页面与 API 已经分得比较清楚,`src/lib` 里哪些能上边缘、哪些必须留后端,界限是清晰的。这不是一个烂摊子。
### 6.3 拆分后的形态
```
前端(EdgeOne Pages / CF Worker)
React SPA(由现有页面改造)+ 调用后端 API
│
▼
后端(一台有文件系统的服务器)
Node 服务 + 复用现有 src/lib(几乎不用改)
→ 读写 md、跑 git、调 hugo、发 RSS
```
必须改的四件事:
1. **认证**:现在是同域 session/cookie,分离后要改成 token(Bearer / JWT)+ CORS
2. **数据获取**:服务端组件直读 md → 改成客户端 fetch API(多一次往返,但要加 loading 态)
3. **图片上传**:写本地磁盘 → 改成对象存储(又拍云 / R2 / EdgeOne KV)
4. **API 地址配置化**:`src/lib/config.ts` 里加后端 base URL
### 6.4 ⚠️ 但先问一句:**为什么要拆?**
如果目的是**"一个统一的管理入口"**(体验问题),那拆分会**让架构变复杂**——从 1 个应用变成 2 个(前端 + 后端),组件数不减反增。这跟你要的"简化"是反方向。
**更省的做法(零重构)**:用**一个域名 + 路径分流**把两个后台挂在一起。
```
admin.usj.cc/ → blog-admin(CF Worker,评论/RSS 管理)
admin.usj.cc/write/ → write-server(写作后台)
```
三种实现,任选:
- **OpenResty 反代**(你国内机已经有):加几行 `location /write/ { proxy_pass ...; }`,**成本最低**
- **EdgeOne 规则引擎**:用 URL 重定向 / 路径规则做分流
- **CF Worker 薄代理层**:能做,但 CF 国内慢,不适合做入口
**判断标准**:
- 只要"一个入口" → **反代,别重构**
- 真的需要"前端独立部署到边缘、后端瘦成纯 API" → 才做拆分,且要接受工作量和组件数增加
---
## 七、建议的推进顺序
### 第 0 步(今天就能做):一次实验,验证单项目可行性
这是**成本最低、信息量最大**的一步,只需改一个参数:
```bash
# 用已有的 token,把产物部署到 global 区域(覆盖大陆)测一次
npx edgeone pages deploy ./public -n hugo-blog -t "$EDGEONE_API_TOKEN" -a global
```
然后用国内机实测(可复用我之前写的 `probe_speed.py` 那套):
- 大陆解析到的 IP 是不是国内 IP
- TTFB 是否降到 100ms 级
**如果单项目 `-a global` 就达标 → 方案收敛为「改一个参数」,收工。**
如果不达标或影响境外 → 再走双项目。
### 第 1 步:确定区域策略
根据第 0 步结果,二选一:
- 单项目 `-a global`
- 双项目(国际站 + 国内站,两套 token)
### 第 2 步:接上大陆域名
国内站的 Pages 项目绑定自定义域名,走 ICP 备案校验(你已有备案号)。
### 第 3 步:砍掉旧链路
确认 EdgeOne 双区域稳定运行 **1–2 周**后,再依次停掉:
- 广州中转机的 `sync.sh` 定时任务(1Panel 里 `id=15` 那个每分钟的)
- 又拍云、多吉云的同步逻辑
- `deploy.yml` 里对应的通知与检查步骤
**不要跳步**,也不要和"write-server 重构"混在同一次变更里。
### 第 4 步(可选,独立评估):write-server
先回答 6.4 那个问题。如果要拆,它是一次独立的重构工程,单独排期。
---
## 八、待核实项(我标出来,不装作确定)
| # | 事项 | 状态 | 怎么验 |
|---|---|---|---|
| 1 | 国际站 Pages 的 `-a global` 是否真能启用大陆节点 | **文档矛盾,待实测** | 第七章第 0 步 |
| 2 | `--area overseas` 当初是否是刻意选择(境外质量考虑) | 待你确认 | 问你自己 |
| 3 | 500KB/s 单文件限速对 7 个 mp4 的实际影响 | 已知限制,待评估 | 测一个最大视频的首字节 |
| 4 | 国内站的备案接入校验是否需要变更接入商 | 待核实 | 绑定域名时会暴露 |
| 5 | EdgeOne Pages 的 Git 集成是否支持 Gitee | 有第三方提到,**未官方确认** | 若支持,可再砍掉 Gitea + act_runner |
> 第 5 条如果成立,价值很大:**Gitee 是国内平台**,用它的 Git 集成自动构建,
> 就能把自建 Gitea + act_runner + 中转机整条链一起砍掉。但需要先确认支持情况。
---
## 九、一句话总结
**你的方向是对的,而且比 CF 方案好——不违规、快 8–10 倍、你已经在用了。**
**但在动手建第二个项目之前,先花十分钟做第七章那个「改一个参数」的实验。**
它可能直接告诉你:**这件事根本不需要两个项目。**
@@ -0,0 +1,159 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# Gitee 方案评估
> 回答:**「Gitee 算不算一个替代方案?」**
>
> **结论**:算,而且它是唯一一个**官方支持作为 EdgeOne Pages 代码源**的国内平台。
> 但有一道硬门槛 —— **Gitee 免费版单仓库上限 500MB,你的 `.git` 实测 588MB,超了 17.6%**。
> 好消息是:**这道门槛跨得过去**(见第四节)。真正要权衡的是另一件事 —— **内容审查**。
---
## 一、实测数据(刚量的,不是估算)
| 指标 | 实测值 |
|---|---|
| `.git` 总体积 | **588 MB** |
| `git count-objects` size-pack | **580 MB**(对象净体积,说明已经打包过了) |
| 提交总数 | 955 |
| **HEAD 中被跟踪文件合计** | **372.9 MB / 2427 个文件** |
| HEAD 中 >1MB 的文件 | 65 个,合计 143.9 MB |
**关键推论**:`580MB(pack) − 373MB(HEAD) ≈ 207MB` 是**历史里的旧版本 / 已删除对象**。
也就是说,**光留当前状态只需要 373MB** —— 这一条决定了瘦身可行。
---
## 二、四条硬指标逐项核对
Gitee 官方配额(help.gitee.com/account/usage-quota):
| Gitee 免费版限制 | 官方值 | 你的情况 | 判定 |
|---|---|---|---|
| 单仓库容量 | **≤ 500 MB** | 588 MB | ❌ **超限 17.6%** |
| 单文件大小 | ≤ 50 MB | 最大 12.8 MB(mp4) | ✅ 安全 |
| 用户总仓库容量 | 5 GB | 588 MB(瘦身后 373 MB) | ✅ 安全 |
| 私有仓库协作人数 | 5 人 | 个人 | ✅ 无关 |
| 仓库数量 | 1000 个 | 1 个 | ✅ 无关 |
**只有一条不过:仓库体积。** 而且它刚好卡在一条"能解"的线上。
---
## 三、一个被大多数人算错的点:Gitee 方案**不消耗** Gitee Go 的额度
Gitee Go 免费额度很小(单仓库 200 分钟永久 + 每月 500 分钟)。**但这跟你的方案无关** ——
因为你走的是 **EdgeOne Pages 的 Git 集成**:代码放 Gitee,**构建跑在 EdgeOne 侧**(4 核 6GB,500 次/月)。
Gitee 在这里**只当代码网盘**,一次构建都不消耗它的 CI 分钟。
> ⚠️ 同理:**不要用 Gitee Pages**。它的构建能力和额度都远不如 EdgeOne,
> 而且 2022 年那次全站审查的触发点之一就是 Pages。
---
## 四、唯一的硬伤与解法:588MB → 500MB 以下
`.git` 588MB,其中 **207MB 是历史垃圾**(旧版本 + 已删除文件)。三种解法:
### 方案 A:只保留当前状态(最彻底,推荐)
重建一个只有 HEAD 的仓库 → `.git` 降到 **≈ 373MB**,**留 25% 余量**。
代价:**955 个提交历史会丢**。
但这在你这儿几乎无损失 —— 历史仍在自建 Gitea 那边完整保留,Gitee 只当"构建用的镜像"。
```bash
# 思路(务必先在副本上试,不要直接动主仓库)
git checkout --orphan clean-tmp
git add -A && git commit -m "squash: 当前站点状态"
git branch -D main && git branch -m main
git reflog expire --expire=now --all && git gc --prune=now
```
### 方案 B:保留历史,只清理历史中的大文件
`git filter-repo --analyze` 先定位占空间的历史大文件,再针对性剔除。
体积介于 373–580MB 之间,**需要实测才知道能不能压到 500MB 以下**,不保证成功。
### 方案 C:升级 Gitee 付费版
标准版单仓库 ≤ 1GB,1298 元/年起 —— **为了一个博客每年花 1300,不值得**。
**结论:只有方案 A 是确定可行的,而且可接受。**
---
## 五、真正要权衡的:内容审查(这条比 500MB 更重要)
Gitee 不是中立的文件存储,它有**实名认证 + 内容审查体系**。有实锤的历史:
- **2022-05-19,Gitee 把全部约 1000 万个公开仓库临时私有化,做强制审核**,合规的才恢复。
开发者必须逐个提交申请、人工复核。有用户 24 个仓库里被恢复了 22 个。
- 审查范围包括仓库名、注释、变量名 —— 有开发者抱怨"写代码时还要想这个词会不会触发敏感词列表"。
- 常见封号原因:仓库含违规内容(赌博/诈骗/侵权/涉政)、批量建仓被判滥用、开 Pages 发不合规内容。
- Gitee 数据全部境内存储,实名是硬要求(这是它拿政府/信创订单的前提)。
### 但落到你身上,风险评估是**低**的
理由三条:
1. **你的内容是技术博客** —— Android 模块化魔改、K3s、Docker、自建服务。这类内容不在敏感区间。
2. **把仓库设为私有**。审查压力的大头在**公开仓库**上(2022 那次动的就是公开仓库)。
你要用 EdgeOne 构建,私有仓库完全可以(授权访问即可)。
3. **国内平台在"政策风险"上对你反而是加分项** —— 你 GitHub 账号被标记,
正说明境外平台对你有不确定性。Gitee/CNB 的规则是**明确、可预期**的:
别传违禁内容就不会出事,而不是"不知道哪天被风控"。
---
## 六、三个国内/境外代码源横向对比
| | **Gitee** | **CNB(cnb.cool)** | GitLab.com |
|---|---|---|---|
| 仓库容量 | 500 MB ⚠️ **需瘦身** | **100 GiB** ✅ 不用动 | 2 GB ✅ |
| EdgeOne Pages 官方支持 | ✅ 明确支持 | ✅(有官方插件文档) | ✅ 明确支持 |
| 构建额度消耗 | 不消耗(构建在 EO 侧) | 不消耗 / 或用它自己的 160 核时 | 不消耗 |
| 登录 | 实名注册 | 微信扫码 | 邮箱 |
| 国内推送速度 | 快 | 快 | 跨境,588MB 首次推送慢 |
| 内容审查 | ⚠️ 有实锤事件 | ⚠️ 官方明写"平台级内容安全审查" | 无(但美国平台) |
| 政策风险 | 低(国内合规) | 低 | ⚠️ **与 GitHub 同源风险** |
| 与 EdgeOne 的距离 | 第三方 | **同为腾讯体系** | 第三方 |
### 排序建议
1. **CNB** —— 100GiB 不用瘦身,零改造,和 EdgeOne 同属腾讯、有官方集成文档
2. **Gitee** —— 官方支持、国内快,但**要先做一次历史瘦身**(一次性工作,可接受)
3. **GitLab.com** —— 容量够,但**美国平台,和 GitHub 同类风险**,且首次推送跨境 588MB 很慢
**Gitee 输给 CNB 的唯一一点就是那 500MB**,如果你不想动仓库历史,选 CNB;
如果你更信任 Gitee 这个老牌子、也愿意做一次瘦身,Gitee 完全可用。
---
## 七、Gitee 方案的完整形态
```
write-server / 本地 hugo → push Gitee(私有仓库)
→ EdgeOne Pages Git 集成自动构建(4核6G,500 次/月,不消耗 Gitee CI 额度)
→ EdgeOne 国际站(境外解析) + EdgeOne 国内站 Makers(境内解析,绑备案域名)
```
砍掉:act_runner、广州中转机、sync.sh、COS、Gitea artifact 链、8 处 Gitea 兼容分支、
又拍云、多吉云、两套冗余通知。
**3 台机器 → 0 台;7 环节 → 3 环节。**
---
## 八、前置动作(按顺序)
1. **先备份**:整仓 `cp -r` 一份(或 `git bundle create` 全量)
2. **在副本上做瘦身**,验证 `.git` 能压到 500MB 以下
3. 注册 Gitee,建**私有**仓库,首次推送验证体积
4. 在 EdgeOne Pages 里接 Gitee 仓库,跑一次构建
5. 确认无误后,才把主仓 remote 切过去
**第 1、2 步不做,其余都别动。**
---
*本文档基于 2026-10-04 实测数据与官方文档。仓库体积为 `git count-objects -vH` 实测,非估算。*
@@ -0,0 +1,202 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 代码源与构建平台选型
> 回答的问题:**「想简化是不是只能走 EdgeOne?」**
>
> **结论先行**:不是"只能",但在你当前的约束下,**EdgeOne 是最省事的那一个**。
> 而且真正要换的不是 CDN —— 是**「托管构建」**。EdgeOne 恰好一条顶两条(构建 + 分发),
> 这是 Cloudflare 和阿里云 ESA 都做不到的。
>
> 另外有一个**会改变结论的数字**:Gitee 免费版单仓库上限 **500MB**,而你的 `.git` 是 **588MB**。
> 所以国内代码源的第一选择不是 Gitee,而是 **CNB**。
---
## 一、把问题重新定义一次
你说「之前是 GitHub Actions 部署,用得很稳,但账号被标记了」。
那么现在这一整套东西,本质是**你自己复刻了一套 GitHub Actions**:
| GitHub Actions 原来的角色 | 你现在用自建件替代 |
|---|---|
| github.com(代码托管) | 自建 Gitea |
| GitHub 的 runner(构建执行) | act_runner |
| `upload-artifact`(产物传递) | Gitea artifact → COS → 中转机 |
| Pages 直连(产物落地) | sync.sh → 又拍云 → 多吉云 |
**所以要"简化回去",正确的问题是:找一个托管的构建服务,把这四个自建件一起替掉。**
CDN 选谁(EdgeOne / CF / ESA / 多吉云)是**另一个问题**,它不解决构建复杂度。
---
## 二、三条硬约束(都核实过,不是印象)
| 约束 | 实测/官方值 | 影响 |
|---|---|---|
| 你的仓库体积 | `.git` **588MB** / 955 提交;工作区 803MB | 决定代码源能不能装下 |
| 你的产物体积 | `public` 424MB / **3267 文件**,最大单文件 12.8MB | 决定托管平台的文件数/单文件门槛 |
| Hugo 版本 | 0.128.2 | 托管构建需能指定版本 |
| 备案 | **鄂ICP备2022015735号-1** | 境内加速的前提,你**已具备** |
---
## 三、★ 三个关键发现
### 发现 1:EdgeOne Pages **官方支持 Gitee / GitLab 作为 Git 源**
官方文档原文:*「Pages 目前支持 GitHub, GitLab, Gitee 等 Git 提供商的接入」*。
第三方 Nitro 部署文档也写:*「EdgeOne supports deployments from GitHub, GitLab, Gitee, and CNB」*。
**这条直接决定了「能不能找回 GitHub Actions 那种简单感」—— 能。**
代码推到国内托管平台 → EdgeOne 自动拉取、自动构建、自动部署。**不需要你自己的 runner。**
### 发现 2:Gitee 免费版装不下你的仓库
Gitee 官方配额页(help.gitee.com/account/usage-quota):
| 项 | 社区版免费额度 |
|---|---|
| 单仓库容量 | **≤ 500 MB** |
| 单文件 | ≤ 50 MB |
| 用户总仓库容量 | 5 GB |
**你的 `.git` 是 588MB → 超了 17.6%。** 推上去会被锁定推拉服务。
(Gitee 提供 `git-repo-clean` 瘦身工具可以降到 500MB 以下,但那是额外工作。)
### 发现 3:CNB 是更合适的国内代码源 —— 而且和 EdgeOne 有官方集成
**CNB(Cloud Native Build,cnb.cool)** 是腾讯的 AI Native Git 平台,社区版免费:
| 项 | 免费额度 |
|---|---|
| 仓库存储 | **100 GiB**(你的 588MB 毫无压力) |
| 对象存储 | 100 GiB |
| 云原生构建 | **160 核时/月** |
| 云原生开发 | 1600 核时/月 |
| 登录方式 | 微信扫码 |
**160 核时换算**:4 核跑 10 分钟 = 4 × (10/60) ≈ 0.67 核时 → 一个月能跑 **约 240 次构建**。你今天按每月 60–90 次算,**用掉不到 40%**。
而且 EdgeOne 官方专门写了 **CNB 插件文档**(`pages.edgeone.ai/document/using-cnb-plugin`):
> 在 CNB 流水线里 `npm run build` → `npx edgeone pages deploy -n <项目名> -t $EDGEONE_API_TOKEN`
**这条路官方背书,100% 可行。**
---
## 四、四种组合对比
| | 代码源 | 构建在哪 | 产物去向 | 自维护机器 | 免费额度 | 卡点 |
|---|---|---|---|---|---|---|
| **① CNB + EdgeOne Pages** ★ | CNB | **CNB 流水线**(托管) | EdgeOne 国际 + 国内 | **0 台** | 100GiB + 160 核时/月 + 500 构建/月 | 需绑微信/腾讯云账号 |
| ② Gitee + EdgeOne Pages | Gitee | EdgeOne Git 集成(4核6G) | EdgeOne 国际 + 国内 | **0 台** | 5GB / 500 构建/月 | **仓库必须先瘦身到 500MB 以下** |
| ③ GitLab.com + CF Pages | GitLab.com | CF 侧 | **仅 CF**(产物拿不出来) | 0 台 | CF 500 次/月 | 境内无解;CF 国内访问慢 3 倍 |
| ④ 保留 Gitea + EdgeOne Pages | 自建 Gitea(mirror→CNB) | CNB | EdgeOne | **1 台**(Gitea) | 同 ① | 没砍干净;但写作后台零改动 |
**为什么推荐 ①**:唯一一个同时满足「代码源在国内且装得下仓库」+「构建托管」+「境内外都能落」+「免费」的组合。
---
## 五、推荐形态
```
写作侧 构建(托管) 分发(托管)
┌────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐
│ write-server │ │ CNB(cnb.cool) │ │ EdgeOne 国际站 Pages │
│ (本地/服务器) │ push │ 免费 100GiB / 160 核时/月 │ │ → usj.cc 境外解析 │
│ 直接写 .md + git ├─────────▶│ │ │ │
│ 只改 remote URL │ │ .cnb.yml: │──▶│ EdgeOne 国内站 Makers │
└────────────────────┘ │ hugo --minify │ │ → 备案域名 境内解析 │
│ edgeone pages deploy │ │ │
或本地 hugo 后手动 push ─────▶│ (境外 + 境内 两个项目)│ └──────────────────────────┘
└──────────────────────────┘
```
### `.cnb.yml` 骨架(复刻你现在 deploy.yml 做的事)
```yaml
main:
push:
stages:
- name: 构建 Hugo 站点
image: klakegg/hugo:0.128.2-ext
script: |
hugo --minify --gc
- name: 部署境外
image: node:20
script: |
npx edgeone pages deploy ./public \
-n hugo-blog-overseas -t $EDGEONE_TOKEN_INTL -a overseas
- name: 部署境内
image: node:20
script: |
npx edgeone pages deploy ./public \
-n hugo-blog-cn -t $EDGEONE_TOKEN_CN -a global
```
> ⚠️ `-a global`(含中国大陆)目前只在**国际站**文档里明确,且官方文档自相矛盾。
> 另一条稳妥路径是**国内站 Makers 单独建一个项目**(账号体系与国际站独立),
> 绑你的备案域名。这个需要实测一次定论。
---
## 六、砍掉 / 保留
### 砍掉(9 项)
| 砍掉 | 原因 |
|---|---|
| act_runner | CNB 托管构建取代 |
| 广州中转机 | 产物直接落在 EdgeOne,不需要中转 |
| `sync.sh` 轮询 | 同上 |
| 腾讯云 COS | 同上(砍 COS 这件事本身也随之结束) |
| Gitea artifact 链 | 那串 `v3/v4` 兼容坑一起消失 |
| 8 处 `github.server_url` 兼容分支 | 不再需要伪装成 GitHub |
| 又拍云 | EdgeOne 国内站直接 serve 境内 |
| 多吉云 | 同上(境内 CDN 层) |
| 三套通知里的两套 | 保留一套即可 |
**发布链路:7 环节 → 3 环节**(写作 → 构建 → 上线)
**自维护机器:3 台 → 0 台**
**厂商:Gitea自建 + COS + 又拍云 + 多吉云 + EdgeOne + 失效的 GitHub → CNB + EdgeOne(同为腾讯体系)**
### 保留
- **Hugo + content + themes** —— 完全不动
- **blog-admin(artalk-cf)** —— 本来就跑在 CF Workers,一行不改
- **write-server** —— **几乎不用改**,只把 `origin` 的 remote URL 指向 CNB。
CNB 是标准 git + 提供统一 HTTPS Token,`lib/git.ts` 的 `git pull --rebase` / `git push` 逻辑照常工作
---
## 七、待确认(3 条,按重要性排序)
1. **EdgeOne 免费版的流量额度**。官方「限制与配额」页**没有列「流量」这一项**;第三方说法互相矛盾(一处说 50GB/月,一处说不限量)。
按你的站点规模估算:单页 1–3MB,50GB/月 ≈ 2–5 万次浏览 —— 个人博客大概率够,
但**那个 12.8MB 的 mp4 如果被反复播放会吃掉不少**。这一条开工前必须先确认。
2. **CNB 是否可直接作为 EdgeOne Pages 的 Git 源导入**。第三方文档说支持,官方只在插件路径里明确。
不影响可行性(走插件路径必然可行),只影响配置方式。
3. **境内用国际站 `-a global` 还是国内站 Makers 独立项目**。一次实验可定论。
---
## 八、最小验证(成本极低,建议先做)
不用改任何现有东西,**一次性验证整条链路是否成立**:
1. 建一个 CNB 仓库(微信扫码,2 分钟)
2. 往里放一个最小 Hugo 站 + 上面的 `.cnb.yml`(或先放个 hello world)
3. 拿一个 EdgeOne API Token(你已经有),跑一次 push
4. 看三件事:**CNB 能不能构建** → **能不能部署到 EdgeOne** → **国内机实测访问延迟**
这一步跑通,等于确认了整条路;跑不通,损失是 20 分钟,不是 30 天。
---
*本文档基于 2026-10-04 核实的官方文档与实测数据。所有配额均引自官方页面,第三方来源已标注。*
@@ -0,0 +1,219 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 砍掉腾讯云 COS 中转层 —— 完整改造步骤
> 目标:把「build → COS → 中转机 → 又拍云 → 多吉云」这条绕路,
> 改成「build → Gitea artifact →(中转机拉取)→ 又拍云 → 多吉云」,
> 砍掉腾讯云 COS 这一层,省下 COS 存储/流量账单。
> 状态(2026-10-04 更新):
> - ✅ `deploy.yml` 已改完并**已推送**
> - ✅ 中转机 `sync.sh.new`(artifact 版)已上传到 `/opt/upyun-sync/sync.sh.new`,**尚未生效**
> - ✅ Gitea 只读 token 已写入中转机 `/opt/upyun-sync/.gitea_token`(600 root:root)
> - ❌ 链路**尚未跑通**:run #55 倒在 v4 不支持,run #56 倒在 v3 的 finalize 500
> - ⏸️ 最新修复(单文件 tar,commit `1f568bbf`)**本地已提交、待推送**
> - ⚠️ `deploy.yml` 与 `sync.sh` 必须一起生效,否则国内线路停更(见下)
---
## 零、实测踩到的两个坑(都已修,记录备用)
### 坑 1:`upload-artifact@v4` 在 Gitea 上直接失败(run #55)
```
::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
are not currently supported on GHES.
```
Gitea 被 v4 识别为 GHES,但它没实现新版 artifact API → **降级 v3**(commit `ed38f938`)。
### 坑 2:v3 传「目录」时 Gitea finalize 返回 500(run #56,耗时 30 分钟)
v3 收目录是**逐文件上传**:3216 个 blob / 376MB 全部传完(日志可见 `Processed file #3216`),
但最后的 finalize 调用被 Gitea 拒绝:
```
Finalize artifact upload - Attempt 1..5 of 5 failed with error: Request timeout
::error::Finalize artifact upload failed: Artifact service responded with 500
```
**结论:Gitea 扛不住「文件数多」的 artifact。** 改法(commit `1f568bbf`):
build 侧先把 `public/` 打成**单个** `public.tar.zst`,只上传这 1 个文件。
副作用是好的——产物结构从此完全确定,不再有「解出来会不会多一层 `public/`」的歧义。
---
## 一、为什么不能只推一半(重要)
deploy.yml 改完后,build 产物**不再上传 COS**,只进 Gitea artifact。
而国内中转机的 sync.sh 如果**还从 COS 拉**,就会:
- build 传 artifact → COS 没有新产物
- 中转机从 COS 拉 → 拿到的是旧产物 / 拉不到
- **国内线路(又拍云 + 多吉云)停更**
所以 `deploy.yml` 和 `sync.sh` 必须**同步改、同步推**,不能只推 deploy.yml。
---
## 二、已完成的改动(deploy.yml)
文件:`.github/workflows/deploy.yml`
### build job
- ❌ 删掉:「Setup Python」「Install/Configure coscmd」「Get last build hash from COS」「Check if upload is needed」「Upload to Tencent COS」「Upload hash to COS」
- ✅ 新增:「Upload build artifact (public/)」→ `actions/upload-artifact@v4`,name=`hugo-public`,path=`public/`,retention-days=7
- 保留:「Report deploy status (built)」上报 hash 到 `api.200181.xyz`
### deploy-edgeone job
- ❌ 删掉:「Check if deployment is needed」(last_hash 判断)、「Setup Python」「Install/Configure coscmd」「Download files from COS」
- ✅ 改成:`actions/download-artifact@v4`(name=`hugo-public`,path=`public/`)→ 直接 `npx edgeone pages deploy ./public`
- `deployed` 恒为 true(push 才触发,本就该部署)
### 清理
- 删掉所有 `download_duration` / `last_hash` / `need_upload` / `coscmd` 引用(通知、表格里的「下载 COS」等)
---
## 三、剩下的改动:sync.sh(国内中转机)
文件:`/opt/upyun-sync/sync.sh`(国内机 119.29.215.187,通过 1Panel API 或 SSH 操作)
### 要改的核心逻辑
原流程(第 1-3 步):
```bash
# 1. 取远端 hash:从 COS 读 /__build_hash(cos_sign.py 签名)
# 2. 比对本地 hash,无变化退出
# 3. 从 COS 下载 public.tar.zst
```
新流程:
```bash
# 1. 调 Gitea API 列出最新 artifact
# GET https://gitea.usj.cc/api/v1/repos/zqlit/blog/actions/artifacts?name=hugo-public
# (Header: Authorization: token <GITEA_TOKEN>)
# 2. 拿最新一个 artifact 的 id,下载 zip
# GET .../actions/artifacts/{id}/zip (302 重定向,curl -L 跟随)
# 3. 解压 zip → 读 public/.build_hash → 比对本地 hash
# 有变化才继续走「upx sync 又拍云 → purge → 刷多吉云」
```
### 需要的准备
1. **Gitea 只读 token**:
- Gitea 后台(gitea.usj.cc)→ 头像 → 设置 → 应用 → 生成令牌
- 名称随意(如 `upyun-sync`),勾选 `read:repository` 即可(只读够用)
- 生成后把 token 存到中转机 `/opt/upyun-sync/.gitea_token`(chmod 600)
2. 中转机访问 Gitea 的网络:已实测 0.56s,稳定,无问题。
### sync.sh 改动示例(第 1-3 步替换成如下)
```bash
# ---------- 取最新构建(从 Gitea artifact,替代原 COS)----------
GITEA="https://gitea.usj.cc"
REPO="zqlit/blog"
TOKEN=$(cat "$ROOT/.gitea_token" 2>/dev/null)
# 1. 列出 hugo-public artifact,拿最新 id + created_at
ART_LIST=$(curl -fsS --max-time 40 -H "Authorization: token $TOKEN" \
"$GITEA/api/v1/repos/$REPO/actions/artifacts?name=hugo-public" 2>/dev/null)
ART_ID=$(echo "$ART_LIST" | python3 -c "import sys,json; a=json.load(sys.stdin); print(a[-1]['id'] if a else '')")
[ -n "$ART_ID" ] || { say "❌ 没有找到 hugo-public artifact"; exit 1; }
# 2. 下载 artifact zip
rm -f "$ROOT/artifact.zip"
curl -fSL --max-time 600 -H "Authorization: token $TOKEN" \
"$GITEA/api/v1/repos/$REPO/actions/artifacts/$ART_ID/zip" \
-o "$ROOT/artifact.zip" || { say "❌ 下载 artifact 失败"; exit 1; }
# 3. 解压(artifact zip 里是 public/ 的内容)
rm -rf "$ROOT/public.new"; mkdir -p "$ROOT/public.new"
unzip -q "$ROOT/artifact.zip" -d "$ROOT/public.new" || { say "❌ 解压失败"; exit 1; }
# 4. 读 hash 比对(.build_hash 在 zip 里 public/.build_hash)
REMOTE=$(cat "$ROOT/public.new/.build_hash" 2>/dev/null | tr -d ' \r\n')
LOCAL=$(cat "$STAMP" 2>/dev/null | tr -d ' \r\n')
if [ "$REMOTE" = "$LOCAL" ] && [ -f "$PUBLIC/index.html" ]; then
say "无变化 ${REMOTE:0:12}…"; exit 0
fi
say "发现新构建: ${LOCAL:0:12}… -> ${REMOTE:0:12}…"
# 后续:原子切换 public.new → public,然后照旧 upx sync / purge / 刷多吉云
# (这部分逻辑不变,只是"下载产物"和"取 hash"的来源从 COS 换成了 artifact)
```
> ⚠️ 注意:artifact 解压出来的目录结构取决于 `upload-artifact` 的 path。
> deploy.yml 里 `path: public/`,所以 zip 里是 `public/...`,解压后要取 `public.new/public/`
> 或调整 path。实测一次 build 看 zip 结构最稳。
---
## 四、执行顺序(照着做)
### 第 1 步:生成 Gitea token
Gitea 后台 → 设置 → 应用 → 生成令牌 → 只读 → 复制 token
### 第 2 步:token 放到中转机
```bash
# 通过 1Panel API 或 SSH(国内机)
echo "你的token" > /opt/upyun-sync/.gitea_token
chmod 600 /opt/upyun-sync/.gitea_token
```
### 第 3 步:改 sync.sh
按上面「三」的示例,把「取 hash + 下载」两段从 COS 换成 Gitea artifact。
### 第 4 步:推 deploy.yml
```bash
cd /e/GitHub/blog
git add .github/workflows/deploy.yml
git commit -m "chore: 砍掉 COS 中转,build 产物改用 Gitea artifact 传递"
git push gitea main
```
### 第 5 步:验证
1. 看 Gitea Actions 这次 build 是否成功(重点看 `upload-artifact` 有没有报错——这是 Gitea 28 兼容性风险点)
2. 看中转机 sync.log 有没有「✅ 同步完成」
3. 打开 `https://api.200181.xyz/api/deploy-status` 看国内线路是否 synced
4. 访问 usj.cc 看国内是否更新
### 第 6 步:确认稳定后,清理 COS
- 删 Gitea secrets 里的 COS_SECRET_ID / COS_SECRET_KEY / COS_BUCKET / COS_REGION(先留着观察几天再删)
- 腾讯云 COS 桶 `hugo-1303964578` 可以清空或删桶(确认不再被引用后)
- 中转机的 `cos_sign.py` 可以删
---
## 五、风险点与回退
| 风险 | 说明 | 对策 |
|---|---|---|
| `upload-artifact@v4` 在 Gitea 28 兼容性 | v4 是新版 action,Gitea 兼容层可能只支持 v3 | 若报错,降级到 `actions/upload-artifact@v3` |
| artifact 保留期 7 天 | 若长时间不部署,artifact 过期 | retention-days 设大,或确认 deploy-status 兜底 |
| zip 目录结构 | `path: public/` 解压后是 `public/...` 还是直接文件 | 实测一次 build 看 zip 结构再定解压路径 |
| 回退 | 万一 artifact 链路不通 | 原 deploy.yml 备份在 `.workbuddy-backup/deploy.yml.20261004-085652`,`git revert` 即可回 COS |
---
## 六、原始备份位置
- deploy.yml 原始版本:`E:/GitHub/blog/.workbuddy-backup/deploy.yml.20261004-085652`
- 已改版本:`E:/GitHub/blog/.github/workflows/deploy.yml`(未推送)
---
## 七、关键信息速查
| 项 | 值 |
|---|---|
| 境外 Gitea | `23.254.236.47:3001`(域名 gitea.usj.cc),SSH root / `***REMOVED-ROOT-PW***` |
| Gitea 数据 | docker `gitea/gitea:latest`,`/opt/gitea/data`,SQLite |
| 国内中转机 | `119.29.215.187:3721`(1Panel),又拍云同步任务 ID=15 |
| 中转机 sync.sh | `/opt/upyun-sync/sync.sh` |
| Gitea artifact API | `GET /api/v1/repos/zqlit/blog/actions/artifacts` 和 `/{id}/zip` |
| 又拍云 | bucket=`imzql`,operator=`1770186415` |
| 多吉云 | AK=`4fa23701981899fb`(sync.sh 内已硬编码) |
@@ -0,0 +1,231 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 精简方案:只留 Cloudflare + Hugo
> 2026-10-04
> 目标:砍掉全部自建基础设施,只保留 **Hugo**(构建)和 **Cloudflare**(一切服务端)。
> 文中所有平台限制均来自官方文档核实,非推测。
---
## 一、决定一切的硬约束
**Cloudflare 唯一不能替你做的是「代码托管」。** 它的两条部署路线直接决定架构形态:
| 模式 | 代码源 | 构建在哪 | 适用场景 |
|---|---|---|---|
| **Git 集成** | **只认 github.com / gitlab.com**<br>(官方原文:*does not currently support connecting self-hosted instances*) | Cloudflare 侧自动构建 | 要「push 即上线」 |
| **Direct Upload** | 不需要 git | 不构建,你本地构建好推产物 | 要「零第三方依赖」 |
> ⚠️ **两种模式在项目创建时定死,事后不能互转**(官方明说)。选错只能重建项目 —— 这是最容易踩的坑。
> 变通办法:建 **Git 集成**项目,之后在 `Settings → Builds` 关掉「自动部署」,再用 `wrangler` 直传 —— 这样两种方式都能用(**反过来不行**)。
### 关键结论
**GitHub 被封 ≠ 必须自建 Gitea。** 你不需要「自建 git 托管」这种复杂度,只需要回答一个问题:
> **你要不要「不开电脑也能发文」?**
---
## 二、两条路线
### 形态 A · 本地构建直传(最简,零第三方)
```
你的电脑 Cloudflare
┌─────────────────────┐ ┌──────────────┐
│ 写 md → hugo build │──wrangler─▶│ Pages (静态站) │
└─────────────────────┘ pages ├──────────────┤
deploy │ Workers (评论/RSS) │
└──────────────┘
```
- **组件数**:1 台自己的电脑 + Cloudflare
- **第三方依赖**:0(连 git 托管都不要)
- **发布链路**:3 步,全在本机
- 代码备份:本地仓库即可,要冗余可推任意 git,或存 CF R2
- **代价**:发文必须开电脑
### 形态 B · GitLab.com 当代码源 + CF 自动构建
```
写 md ──push──▶ gitlab.com ──自动构建──▶ Cloudflare Pages ──▶ 上线
Cloudflare Workers (评论/RSS)
```
- **组件数**:gitlab.com + Cloudflare
- **★ 关键**:构建跑在 **Cloudflare 侧**(免费 500 次/月、单次 20 分钟超时),**不消耗 GitLab 的 400 分钟**
→ 所以之前担心的「GitLab 免费版 400 分钟/月不够用」在这套组合下**根本不成立**
- 额度核对:本站 3267 文件 / 最大单文件 12.8MB → CF 上限 20,000 文件 / 25MiB ✅ 完全够
- **代价**:多一个外部账号
---
## 三、可以整个删掉的(9 项)
| # | 删掉的 | 原本的作用 |
|---|---|---|
| 1 | **Gitea** | 自建 git 托管(GitHub 被封后的替代品) |
| 2 | **act_runner** | 自建 CI runner |
| 3 | **广州中转机 + `sync.sh`** | 每分钟轮询、把产物搬去又拍云 |
| 4 | **又拍云** | 国内源站 |
| 5 | **多吉云** | 国内 CDN,回源又拍云 |
| 6 | **EdgeOne Pages** | 境外线路托管 |
| 7 | **腾讯云 COS** | 境外产物 → 国内的中转层 |
| 8 | **artifact 传递链** | 跨 job 传 424MB(含 v3/v4 那串坑) |
| 9 | **兼容分支与冗余通知** | `github.server_url` 判断 ×8、TG/飞书/邮件三套、`if:false` 死代码约 100 行 |
**发布链路从 7 环节降到 3 环节;需要自己运维的机器从 3 台降到 0 台。**
---
## 四、Cloudflare 侧能白捡留下的
**`blog-admin/`(artalk-cf 评论后端 + RSS 机器人)本来就是 CF Workers + D1 + KV —— 一行都不用改。**
这是你现有架构里唯一「已经完全符合目标形态」的部分:
- 评论系统 → `api.200181.xyz`,照常
- RSS 订阅抓取、友链朋友圈数据 → 照常
- 后台管理(`/admin/` 与 `/sidebar/`)→ 照常
**换句话说:你的「后端」其实早就已经是 Cloudflare 了,复杂的只是「发布管线」。**
---
## 五、必须接受的代价(诚实说)
### 1. 国内访问会变慢 ← 最大的一笔
Cloudflare 免费版在中国大陆走**境外节点**(没有 ICP 备案就用不了大陆节点,China Network 要企业版)。
现在这套「又拍云 + 多吉云」的存在意义**就是为了这个**。
**2026-10-04 国内机实测**(腾讯云广州,见「五·补」章):CF 首字节 **0.61–0.89s**,
而现在的国内 CDN 是 **0.18–0.32s** —— 即 **CF 比国内 CDN 慢约 2–4 倍**。
能开、不算快,对个人博客通常可接受,但你要心里有数。
### 2. 写作后台(`post.usj.cc`)会没有
那套 Next.js 直接读写本地 `.md` 文件、依赖文件系统,**CF 上跑不了**。要么:
- 重写成 CF Worker(内容改存 R2/D1,图片存 R2)—— 你会写 Worker,技术可行,但是工作量
- 退回本地写作(配合形态 A 正好)
### 3. Telegram bot 发文、公众号发布会断
同理,需要重写成 CF Worker 才能保留。
### 4. 构建前的脚本要处理
`check_links.js`(友链检查)、`generate_circle_data.js`(朋友圈数据)、`add_draft_to_hidden.py` 这些:
- 要么塞进 CF Pages 的构建命令里(需要 `npm ci`,会拖慢构建、且 CF 构建环境要能装依赖)
- 要么去掉(友情链接检查其实没必要每次构建都做)
---
## 五·补、国内访问能不能救?(2026-10-04 国内机实测)
用国内那台机器(腾讯云广州,`119.29.215.187`)实测,每项 3 次取代表值:
| 目标 | 首字节 TTFB | 连接建立 | 走哪个节点 |
|---|---|---|---|
| `usj.cc`(现状:EdgeOne **国内**节点) | **0.18–0.32s** | 0.01–0.04s | 成都 / 浙江 |
| `api.200181.xyz`(你自己的 CF Workers) | **0.61–0.89s** | 0.20–0.45s | CF Anycast(IPv6) |
| `cravatar.cn`(头像是 CF 的) | 0.65–0.71s | 0.23s | CF Anycast |
| `spst2.com`(页首统计脚本) | 0.087s | 0.011s | 有国内节点,**不是问题** |
**两条硬结论:**
1. **CF 免费版在国内比国内 CDN 慢约 2–4 倍**(TTFB 0.6–0.9s vs 0.2–0.3s)。
2. **实测发现 CF 的连接走了 IPv6**(`2606:4700::…`),握手要 0.20–0.45s;
同一时刻 IPv4 握手只要 **0.058s**。→ **IPv6 到 CF 的路由在国内明显更差**,
这是一个可以单独利用的抓手。
### 能加速的三条路
**路 A · 官方(走不通)**
Cloudflare China Network 需要:Enterprise 企业版 + 单独订阅 + **每个主域名持有效 ICP 备案** +
京东云内容审核。个人博客没戏。
**路 B · 社区「优选 IP」(有效,但违反 ToS)**
原理:CF 用 Anycast 广播,大陆解析到的 IP 段路由差。做法是**让大陆用户的 DNS 解析结果
指向另一段「对大陆友好」的 CF IP**。
- 预期效果:按本机实测基线,合理预期是 **0.6s → 0.2–0.3s**(社区常说的「5s→2s」是针对更差的默认线)
- ⚠️ **Pages 不能直接改 CNAME**(会返回 `1001`)→ 必须「转成 Worker」或「交给第三方 DNS 分线路解析」
- ⚠️ **明文违反 CF 服务条款 2.2.1(b)**:*causing traffic for your Cloudflare-proxied domain
to be sent to an IP address that was not assigned by Cloudflare for the domain*。
官方保留封号权;社区共识是「用别怕,怕别用」,建议**账号隔离**(主站一个号,优选另开小号)
- ⚠️ **要维护**:优选 IP/域名会失效,得定期更换;个别优选域名还会被地方屏蔽
**路 C · 不违规的优化(零风险,先做)**
- ✅ **已做**:字体自托管(`/font/zql-v2.woff2`,无 Google Fonts)、CSS 合并成单文件、图片 WebP
- 头像源 `cravatar.cn` 走 CF(0.65s)→ 可换国内源(站内已有 `q1.qlogo.cn` 这类)
- 加 **Cache Rules** 长缓存 + **Early Hints** + **Tiered Cache**(免费可用)→ 改善重复访问
- 若走第三方 DNS(路 B 顺带),**只下发 A 记录、不下发 AAAA** → 直接规避上面那条差的 IPv6 路由
### 一句话结论
**你自己的实测就是答案:国内 CDN 快 2–4 倍,而且完全合规。**
CF 免费版在国内的「加速」本质是**可用的灰色技巧 + 持续维护成本**。
- 如果「只留 CF」是硬目标 → 走 **Worker + 第三方 DNS 只给 IPv4**(比优选 IP 干净,且顺带修 IPv6 问题)
- 如果真正的目标是「砍复杂度」→ 记住:**复杂度的大头在「构建与搬运」**(Gitea / act_runner / 中转机 / COS / artifact),
**不在 CDN 的数量**。多留一个国内 CDN 不会让架构变复杂;砍掉那串搬运链才会。
---
## 六、我的建议
**先回答那个问题:需不需要「不开电脑也能发文」?**
| 你的答案 | 选 | 理由 |
|---|---|---|
| **不需要**(本来就在电脑上写) | **形态 A** | 真正的「只要 CF + Hugo」:0 台服务器、0 个第三方托管 |
| **需要**(手机/TG 发文) | **形态 B** | GitLab 只当「代码网盘 + 触发器」,构建和托管全在 CF |
**两条都不要再去自建 Gitea** —— 那正是你想摆脱的复杂度。
**一个必须提前想清楚的点**:CF Pages 的 Git 集成与 Direct Upload **不能互转**,项目创建时就要定。
如果不确定,**先建 Git 集成项目、之后关掉自动部署用 wrangler 直传**,把两条路都留着。
---
## 七、如果还想更彻底(建议不要)
把内容也搬进 CF:文章存 R2/D1,写作后台写成 CF Worker。但 ——
**Hugo 构建需要文件系统 + 执行二进制,跑不进 Worker。** 所以要么放弃 Hugo(改成运行时渲染),要么保留一个构建执行者(那就回到形态 B 了)。**复杂度会绕回来,不建议。**
---
## 附 A:落地顺序(以形态 B 为例)
1. GitLab.com 建**私有**项目 → 现有仓库推上去(`.git` 588MB,首次跨境推送需要时间)
2. CF Pages 新建项目 → **Connect to Git → 选 GitLab**
- 构建命令:`hugo --minify`
- 输出目录:`public`
- 环境变量:`HUGO_VERSION=0.128.2`(CF 构建镜像默认是 0.147.7,要锁到你的版本)
3. 绑定自定义域名 `usj.cc`
4. `blog-admin` 保持不动(本来就在 CF)
5. 停掉 Gitea / act_runner / 中转机的同步计划任务;DNS 切到 Cloudflare
6. **观察国内访问速度** —— 这是唯一的实质性风险点
## 附 B:形态 A 的落地顺序
1. 本机装 `hugo` 0.128.2 + `wrangler`(Node 环境已有)
2. CF Pages 新建 **Direct Upload** 项目(**注意:一旦选它,以后不能改成 Git 集成**)
3. 本机 `hugo --minify && npx wrangler pages deploy public --project-name=<项目名>`
4. 绑定域名,完成
5. 其余全部关停
---
## 附 C:本方案的证据出处
- Cloudflare 官方文档 *Git integration*:「Pages offers support for GitHub and GitLab」「does not currently support connecting self-hosted instances of GitHub or GitLab」
- 同上:「You cannot switch to Direct Upload later」
- Cloudflare 官方文档 *Direct Upload*:Wrangler 上传上限 **20,000 文件 / 25 MiB**;拖拽上限 1,000 文件
- Cloudflare 官方文档 *Workers Billing and Limitations*:静态资源请求**免费且不限量**,20,000 文件 / 25 MiB
- Cloudflare Blog:*Cloudflare Pages 现已提供对 GitLab 的支持*
- CF Pages 免费额度:500 次构建/月、1 并发、单次 20 分钟超时
- 本站实测:`public/` = 424MB / 3267 文件,最大单文件 12.8MB
@@ -0,0 +1,172 @@
> 📦 **本文档已归档**(2026-10-06)。它记录的是**当时的评估与方案**,其中的结论可能已被后续决策推翻。
> **请勿据此判断当前架构** —— 现行架构唯一事实源是 [`架构总览.md`](../../../架构总览.md)。
# 阿里云 ESA 评估(对比 EdgeOne)
> 2026-10-04 · 问题:「阿里云也有个跟 EdgeOne 很像的服务,能不能考虑一下?」
> 现状:`usj.cc` 域名 **境外解析在 EdgeOne、境内解析在多吉云**(DNS 分线路)
## 一、结论先说
**能考虑,但对你这个站有一条硬伤,直接卡住:ESA Pages 每个项目最多 2000 个文件,你的产物是 3267 个。**
而 EdgeOne Pages 是 **20000 个**——你只用了 16%。
所以结论是:**换阿里云 ESA 不是"简化",是"降级到刚好装不下"。**
---
## 二、阿里云对标的到底是什么
| 腾讯云 | 阿里云 |
|---|---|
| EdgeOne(边缘安全加速平台) | **ESA(Edge Security Acceleration)** —— 就是原来的 **DCDN 改名** |
| EdgeOne Pages(Git 导入 + 自动构建 + 静态托管) | **ESA Pages**(也叫「函数和 Pages」) |
| Edge Functions / Cloud Functions | **ESA 边缘函数**(V8 Isolate,只支持 JS) |
| 节点 | 阿里云 3200+ 边缘节点 |
**能力确实是同级的**——都免费、都支持 ICP 备案、都能绑备案域名走大陆节点、都是「Git 导入 + 自动构建 + 全球分发」一套。你问得没错,这是对标产品。
**但两者的尺寸门槛差了 10 倍,这是分水岭。**
---
## 三、★ 决定性的一张表:文件数门槛
我逐个查了官方配额页(不是二手说法):
| 项目 | **ESA Pages** | **EdgeOne Pages** | Cloudflare Pages |
|---|---|---|---|
| **单项目文件数** | **2000** ❌ | **20000** ✅ | 20000 ✅ |
| 单文件大小 | 25 MB | 25 MB | 25 MiB |
| 源码包大小 | 1024 MB | — | — |
| 总存储容量 | — | 5 GB | — |
| 构建次数 | — | 500 / 月 | 500 / 月 |
| 构建超时 | — | 20 分钟 | 20 分钟 |
| 构建算力 | — | **4 核 6 GB** | — |
| 自定义域名 | — | 200 个 | — |
| **你的站** | **3267 → 超限 63%** | 3267 → 占 16% | 占 16% |
来源:阿里云官方文档《函数和 Pages 使用限制》——*"Pages 文件数 2000 个:每个 Pages 项目最多可上传 2000 个静态文件"*;EdgeOne 官方《限制与配额》——*"单项目文件数 20000"*。
**这一条把 ESA 从候选名单里筛掉了,其他指标都不用比了。**
> 附带发现:**EdgeOne Pages 的构建环境是 4 核 6 GB** —— 比你那台广州中转机(2 核 1.9 G)强得多。如果走它的 Git 集成自动构建,hugo 构建完全不需要你自己的机器。
---
## 四、你的 3267 个文件是怎么构成的
```
1337 html ← 文章页 + 分类/标签/归档页
1211 png ┐
295 webp │
254 jpg ├─ 图片/视频类合计 1778 个(占 54%)
7 mp4 │
6 jpeg │
3 svg ┘
66 xml ← sitemap / rss
59 js
3 woff2 / 3 woff / 3 mp3 / 3 css / 6 json / 2 txt
────────────────
3267 总计
```
**纯文本类(html/xml/css/js/json)只有 1473 个** —— 这个数字很关键,它意味着「如果非要用 ESA」还有一条窄路(见第六章)。
---
## 五、ESA vs EdgeOne:真实差异(去掉重复项之后)
免费套餐**大部分能力是重复的**,真正有差别的是这几条:
| 维度 | ESA | EdgeOne | 谁赢 |
|---|---|---|---|
| **文件数** | 2000 | **20000** | **EdgeOne(决定性)** |
| 大陆节点 | 有(需备案) | 有(需备案) | 平 |
| 流量 / 请求 | 不限 | 不限 | 平 |
| **开通大陆加速的门槛** | ⚠️ **默认不含大陆,要发帖解锁** | 直接可用 | **EdgeOne** |
| 免费套餐数量 | 每账号限 **1 个** | 站点 1 主域 + 200 子域 | EdgeOne |
| 构建算力 | 未披露 | 4 核 6 GB | EdgeOne(已知值) |
| 单文件限速 | 官方未披露;社区称"不限速",另有称峰值 5 Mbps —— **存疑** | 单文件单线程 4 Mbps(≈500 KB/s) | **ESA 可能赢** |
| WebSocket | 免费版**不支持**(第三方实测) | — | EdgeOne |
| 速度(第三方实测) | 大陆节点覆盖**更多**、网络速度**更高** | 加速效果更佳、全球覆盖更广(Anycast) | 各有说法 |
| 迁移成本 | 要重接一次 | **你已经在用** | **EdgeOne** |
### 值得注意的两点
1. **ESA 免费版默认不含中国大陆加速** —— 官方原文:*"By default, this plan does not include Chinese mainland acceleration."* 要解锁得**在任意社交/博客平台发一篇 30 字以上的推荐帖**(带 `#AlibabaCloudESA #ESAPages` 标签 + 官方图),再进群提交链接。你写博客顺手能完成,但这是个**额外操作**,跟"简化"反向。
2. **ESA 免费版的「不限速」有矛盾说法** —— 一方说无限速适合大流量,一方说峰值带宽约 5 Mbps。如果你的痛点是那个 12.8 MB 的 mp4(EdgeOne 下首载约 25 秒),这一条**值得实测**,但它是 ESA 唯一可能赢的地方。
---
## 六、如果非要用 ESA:唯一的一条路
把 **1778 个图片/视频剥离到对象存储**(OSS / 又拍云 / 多吉云存储),Pages 只托管 1473 个文本文件 → 落回 2000 以内。
**但代价是**:多一个存储服务、多一套域名与缓存配置、构建产物要改写图片链接。**这是"增加复杂度"换"换一家厂商",跟你的目标正好相反。**
不建议。
---
## 七、ESA 唯一值得拿的东西(互补用法)
**如果只是想用阿里云,不要用 ESA Pages,用 ESA 的 CDN 加速能力。**
ESA 免费版(Entrance plan)本质是**一个不限流量的 CDN**,可以回源到任意源站。它能做的是:
- 回源到你的又拍云 / OSS 存储 → 只加速,不托管,**没有 2000 文件限制**
- 用 ESA 的**边缘函数**收编现在的 `blog-admin`(CF Workers)
这确实有个价值:**阿里云一家能同时给你 CDN + Pages + 边缘函数 + 对象存储**,比「EdgeOne + CF Workers + 又拍云 + 多吉云」更统一。
但这又引入了**第四家厂商**,而你现在的问题恰恰是厂商太多。
---
## 八、而且你现在的双线路,其实已经是"简化过"的形态
你说的「境外 EdgeOne、境内多吉云」,本身就是**分线路 DNS 调度**的结果:
```
usj.cc
├─ 境外线路 → EdgeOne(Pages 项目,--area overseas)
└─ 境内线路 → 多吉云 CDN(融合 CDN,70+ 节点,20 GB/月免费)
```
**这已经做到了"境内外分流"** —— 跟 EdgeOne 双区域方案想达成的效果是一样的。所以真正的问题不是「CDN 选阿里云还是腾讯云」,而是:
> **要不要把这两条线合并成一家?**
- **合并到 EdgeOne**:改一个参数(`-a global`)或建国内站项目 → 砍掉多吉云 ✅
- **合并到 ESA**:从零接一次,还撞上 2000 文件上限 ❌
- **维持现状**:多一条线,但**不增加架构复杂度**——CDN 数量不等于复杂度
**注意最后一条**:你架构的复杂度大头在**构建与搬运**(Gitea、act_runner、中转机、COS、artifact 链),不在 CDN 数量。多留一个多吉云,不会让架构变复杂;砍掉那条搬运链才会。
---
## 九、建议
1. **别换 ESA Pages** —— 2000 文件上限是硬伤,你的站 3267,装不下。
2. **想收敛到一家,就走 EdgeOne** —— 你已经在用,同一个控制台、同一个项目(`-a global` 实验),或者建个国内站项目。迁移成本近零。
3. **多吉云可以留着** —— 它免费 20 GB/月、融合 CDN(依托大厂节点)、速度好,而且它是**独立的国内线路**,跟 EdgeOne 互为备份。不构成复杂度问题。
4. **如果哪天站点瘦身到 2000 文件以内**(比如把 1778 个图片全挪到对象存储),ESA 会重新变成候选 —— 但那时 EdgeOne 也一样能用,还是没必要换。
---
## 十、待核实项(不装作确定)
| # | 事项 | 状态 | 怎么验 |
|---|---|---|---|
| 1 | 多吉云当前的回源源站是谁(又拍云存储 / EdgeOne / 其他) | **待确认** | 多吉云控制台看回源配置 |
| 2 | ESA 免费版是否真的"不限速"(vs 峰值 5 Mbps) | **说法矛盾** | 需实测,但没必要为它迁移 |
| 3 | ESA Pages 是否支持 Gitee 作为代码源 | 官方只列 GitHub,第三方提到 Gitee | 若成立,对简化链路有价值 |
| 4 | EdgeOne Pages 的 Git 集成是否支持 Gitee | 上次标的第 5 条,仍未确认 | 若支持可砍掉自建 Gitea + act_runner |
---
## 十一、一句话总结
**阿里云 ESA 确实是对标 EdgeOne 的产品,能力同级——但 ESA Pages 限 2000 文件,你的站 3267 文件,直接在门口就被拦下。**
**换厂商解决不了你的问题;你的问题在构建与搬运链,不在 CDN 选谁。**
+142
View File
@@ -0,0 +1,142 @@
# 架构精简候选
> **用途**:本文是**待决策清单**,不是现状描述。现状看 [`../架构总览.md`](../架构总览.md)。
> 每条都是「已确认可以动、但需要你拍板」的项,附收益 / 代价 / 风险 / 怎么做。
>
> 更新时间:2026-10-06
---
## 一、复杂度的真实来源:不是「件数多」,是「跨了 6 个环境」
| 环境 | 谁在维护 | 上面跑什么 | 是否必需 |
|---|---|---|---|
| **腾讯云 CNB** | 托管 | 代码主仓 + 构建 + 发布(7 个 stage) | ★ 必需(发布的骨架) |
| **Cloudflare** | 托管 | 评论后端 + RSS + 证书只读面 + D1/KV + 200181.xyz 的 DNS | ★ 必需(零运维) |
| **国内机 `119.29.215.187`** | **自己** | 9 个容器(见下)+ 一堆手工 vhost | 部分必需 |
| **境外 VPS `23.254.236.47`** | **自己** | Gitea(代码辅仓) | ⚠️ **2026 年 11 月到期** |
| **家庭 F50 / OpenList** | **自己** | 离线加密备份 | ★ 必需(唯一的离线层) |
| **本机 Windows** | **自己** | 备份计划任务、本地写作前端 | 半必需 |
> **四台自维护设备 = 复杂度的真正来源**,而不是「用了几个云服务」。
> 托管服务再多也不累人(出问题找平台),自己维护的机器每一台都要有人记得它。
国内机 9 个容器里,**属于本项目的只有 3 个**:
| 容器 | 归属 | 状态 |
|---|---|---|
| `editor-api` | 本项目 | ✅ 写作后台的线上发布入口 |
| `cn-certkeeper` | 本项目 | ✅ 证书签发 + 部署 |
| `cn-dns-helper` | 本项目 | ⚠️ **疑似遗留**(见候选 2) |
| `1Panel-certimate-OKCO` | 历史遗留 | ⚠️ **可清**(见候选 1) |
| `1Panel-openresty` / `-mysql` | 用户的 | 面板与站点 |
| `1Panel-alist` / `vaultwarden` / `1Panel-frps` | 用户的 | 与本项目无关 |
→ **能动的只有两个**,而且都很干净。
---
## 二、精简候选
### 候选 1 ★ certimate 容器 —— 停掉 `1Panel-certimate-OKCO`
| | |
|---|---|
| **现状** | 容器还在跑(`certimate/certimate:v0.4.32`)。但 **7 个工作流里「会签发并部署」的 2 条已停用**(`enabled=0`),只剩 3 条纯监控告警 |
| **为什么可以停** | 本项目已**完全接管**签发与部署。certimate 现在唯一的作用是「万一要回滚」,而它的凭据早就导出到 `secrets-backup/`,库文件也有 `.bak-*` |
| **收益** | 少一个常驻容器(内存是这台机器的短板:总 1967 MB / 已用 791 MB);少一个「静默竞争」风险源 |
| **风险** | 低。但**告警能力会消失** —— 三条监控工作流会随之失效。而本项目的证书监控已由 `cn-certkeeper` 的 `/status` + Worker 后台接管,**功能不重叠** |
| **怎么做** | `docker stop 1Panel-certimate-OKCO` → 观察 1~2 周(覆盖一次 04:10 自动续期 + 一次证书到期检查)→ 确认无碍再考虑删容器与数据卷。**别急着删**,先 stop |
> ⚠️ 若将来要停用,先确认「证书到期提醒」这一层有人接手(现在是 Worker 后台 + 探针)。
---
### 候选 2 ★ `cn-dns-helper` 容器 —— 大概率已是遗留
| | |
|---|---|
| **它是什么** | 「DNS-01 助手」:接受 `POST /dns/txt`,代调用有 DNS 权限的 Cloudflare API 写 TXT 记录 |
| **当初为什么有** | Phase 1 时代**签发跑在 CF Worker 里**,而 Worker 那份 CF token 是 Workers/KV/D1 专用的(没有 Zone/DNS 权限)→ 只好让国内机代写 |
| **为什么现在不需要** | ★ 签发已搬到 `cn-certkeeper`(国内机),它**自带 dnsprovider**,直接调腾讯云 DNSPod / Cloudflare API。实测 `/preflight` **7/7 全过**,其中 `cloudflare → 200181.xyz` 报「**可读**」= 这份凭据有 Zone 权限 → **不需要中转** |
| **代码侧证据** | `deploy/cn-certkeeper/lib/dnsprovider.js` 里 `remote` 型是**兜底分支**(注释写明「用于 CF token 无 DNS 权限的场景」);Worker 侧 `dnsremoted.ts` **已无任何引用** |
| **收益** | 少一个常驻容器 + 少一个对外 HTTP 端点(虽然只绑回环) |
| **风险** | 低,但**要实测确认**:万一某条域名的 DNS-01 仍走 `remote`,停掉会让那次续期失败 |
| **怎么验证** | ① 查 `cn-certkeeper` 的凭据里有没有 `remote` 型(`/preflight` 的 dns 项只列了 `tencent-usj` / `tencent-tt` / `cloudflare`,**没有 remote**)② 停容器 → 手动跑一次单域续期(`renew-one.mjs`)→ 成功即可判死 |
| **结论** | **建议停掉**,跑一次续期验证 |
---
### 候选 3 Gitea —— 迁到新机,还是干脆不留
**这是本轮最大的一刀**,完整分析见 [`Gitea迁移指南.md`](Gitea迁移指南.md) 第九节。摘要:
| 选项 | 收益 | 代价 |
|---|---|---|
| **A. 迁到新机** | 保留「在线可 clone、可按提交追溯、不受平台规则约束」的副本 | 继续养一台机器 + 一个要运维的实例(**且 Gitea 不参与构建、不参与发布**) |
| **B. 不迁,去掉辅仓** | 少一台机器、少一个部件,架构回到 **CNB 主仓 + F50 离线加密 bundle** 两层 | 少一层在线冗余(但离线层是**完整历史**,且已带失败告警) |
| C. 换托管平台私有仓 | 不用自己运维 | CNB 已是托管平台,再挂一家同性质的收益有限 |
**判断依据**:Gitea 现在挡的是「CNB 跟 GitHub 一样出问题」——
但**离线 bundle 已经覆盖了这个场景**。Gitea 多出来的独有价值只有
「**在线可浏览**」与「**可增量拉取**(不用等一天一次的备份)」。
这两点用不上,**B 就是合理的简化**。
> 若选 B:`deploy/gitea/` 留在仓库里即可(部署包与迁移清单留着,随时能重建),
> 然后按 `Gitea迁移指南.md` 第三节把那 9 处 `gitea` 引用清掉 ——
> **别忘了线上那两处**(`/srv/editor-api/.env`、`/srv/blog` 的 remote)。
---
### 候选 4 写作前端两套并存(`write-server/` vs `write/`)
| | |
|---|---|
| **现状** | `write-server/`(Next.js,线上 `post.usj.cc`)+ `write/`(本地 Windows 前端),**架构总览已注明「功能重叠」** |
| **另外** | `editor-api/`(国内机)才是**真正的那条发布入口** —— 后台发的文章走它 |
| **收益** | 少维护一套前端;文档里的「写作系统」不再需要解释三个东西的关系 |
| **风险** | 取决于你实际用哪个。若两套都在用,就不是「精简」而是「迁移」 |
| **建议** | 先确认实际使用频率,再决定砍哪个。**本轮不动** |
---
### 候选 5 三个手工 vhost 的证书(不是精简,是收尾)
`writeapi.usj.cc` 的证书**没被本项目纳管**(配置在 `/www/conf.d/`,不在 1Panel 站点树里),
实测仍是 **RSA / 到期 2026-12-07**,而本项目续期不会更新它 → **12 月会断**。
两条路:
- **A. 让 cn-certkeeper 覆盖它**:把 `/www/conf.d/writeapi.usj.cc.conf` 的证书路径指到
`openlist.usj.cc` 那种「已被纳管」的文件,或把它纳入部署目标
- **B. 在 1Panel 里把它建成正式站点**,自然被 `ssl/upload` + `sslID` 覆盖
详见 [`../架构总览.md`](../架构总览.md) §6 待办 #13。
---
## 三、明确**不建议**动的(必要复杂度)
| 项 | 为什么保留 |
|---|---|
| **又拍云 + 多吉云 + EdgeOne 三个分发目标** | 境内/境外双线路是业务需求(境内走又拍云→多吉云,境外走 EdgeOne),不是历史堆积 |
| **Cloudflare Workers 全家(评论 + RSS + 证书只读面)** | 零服务器零运维,是本项目**最省事**的一层 |
| **国内机的 `editor-api`** | 写作后台的发布入口,没有替代品(CF Worker 跑不了 git push) |
| **国内机的 `cn-certkeeper`** | CF Worker 免费版 CPU 硬顶 10ms,签发跑不动;用户已否决 CF Paid(比这台机器还贵) |
| **家庭 F50 上的离线备份** | 唯一不依赖任何平台账号的层 |
| **三层留存** | 三层**失效模式不同**,不是重复(见 `架构总览.md` §5.3) |
---
## 四、建议的执行顺序
```
1. 【立刻】候选 5 —— writeapi.usj.cc 证书纳管(12 月会断,有死线)
2. 【本周】候选 1 —— stop certimate 容器(零风险,观察即可)
3. 【本周】候选 2 —— 停 cn-dns-helper + 跑一次续期验证(大概率能省一个容器)
4. 【11 月前】候选 3 —— Gitea:迁 or 不留(★ 必须决定,机器要到期了)
5. 【有空】候选 4 —— 写作前端收敛(先看使用频率)
```
> 前三项做完,国内机上属于**本项目**的容器从 3 个降到 1 个(只剩 `editor-api`),
> 自维护环境从 6 个降到 5 个(去掉境外 VPS)。
> **这才是「感觉复杂」的真正解药 —— 减的是「要记得它」的东西,不是「存在于架构图里」的东西。**
+977
View File
@@ -0,0 +1,977 @@
# 证书管家
> **文档定位**:本文既是**现状说明**(第一~二节 + §9.13),也是**决策沿革**(§8~§9,
> 含踩坑与实测数据)。要了解当前架构看前半部分;要改代码前先看 §8.4 / §9.9 / §9.11 / §9.12
> 这几节「易误判、勿回退」的坑。
>
> ⚠️ 标题曾叫「函数版证书管家(CF Worker)实施方案」—— 那是 Phase 1 的形态。
> **2026-10-06 晚定案后,签发与部署已搬到国内机容器**,Worker 只剩只读监控与后台。
> 沿革见 §9.1 与 §5.6;现行部署单元 `deploy/cn-certkeeper/`。
## 当前状态速览(2026-10-06 收工)
| 项 | 现状 |
|---|---|
| 签发 + 部署 | **国内机 Docker 容器 `cn-certkeeper`**(只绑 `127.0.0.1:8019`),每日 04:10 自动续期 |
| CF Worker(`blog-admin/`) | **只读**:监控 / 后台 / 探针。`/ssl/issue` 已改 **501 硬拒绝**,证书 cron 已移除 |
| 纳管域名 | `usj.cc` / `t-t.live` / `200181.xyz` 三组(事实源 `blog-admin/tools/certkeeper-config.mjs`) |
| 部署目标 | 多吉云 CDN + 1Panel(`119.29.215.187:3721`) |
| CA | LiteSSL / freessl.cn(EAB 继承 certimate 账户,见 §9.10) |
| 自测 | 125/125 通过;`npm run typecheck` 零错误 |
| 数据目录 | 容器内 `/data`,7 条凭据(AES-GCM)+ 1 条 config |
| **已知遗留** | `writeapi.usj.cc` 等 3 个手工 vhost 不在站点管理面内(§9.14 末节)—— 证书是文件拷贝,需单独纳管 |
---
## 原始方案(Phase 1 形态,保留供对照)
> **当初目标**:用 Cloudflare Worker 取代服务器上的 certimate,
> 申请 + 续期 + 部署证书(多吉云 CDN + 1Panel),把 certimate 这个服务精简掉。
> —— ⚠️ 该目标**已被 §9 推翻**:Worker 免费版 CPU 硬顶 10ms/请求,签发跑不动,改由国内机承担。
**当时确认的决策(2026-10-06)**
| 项 | 决定 |
|---|---|
| CA | **继续用 LiteSSL / freessl.cn**(与现状一致,90 天免费) |
| DNS-01 | **对接各家 DNS API,不改 DNS**(腾讯云 DNSPod + Cloudflare) |
| 部署目标 | **多吉云 CDN + 1Panel**(放弃又拍云) |
| 1Panel 接入 | **直连公网 `119.29.215.187:3721`** |
| 覆盖域名 | `usj.cc` / `t-t.live` / `200181.xyz`(三组,多域名) |
---
## 一、现状盘点(从服务器上的 certimate 导出)
### 1.1 三条「申请工作流」(2026-10-06 从 certimate SQLite 导出)
| 工作流 | cron | 域名 | CA | DNS | 部署 | 算法 |
|---|---|---|---|---|---|---|
| 优世界证书申请 | `10 12 * * *` | `*.usj.cc;usj.cc` | `litessl` | **tencentcloud-dns** | **dogecloud-cdn** + **1panel** | RSA2048 |
| 团团证书申请 | `05 12 * * *` | `t-t.live;*.t-t.live` | `litessl` | **tencentcloud-dns** | **1panel** + tencentcloud-eo | RSA2048 |
| 200181.xyz 证书申请 | `17 12 * * *` | `*.200181.xyz;200181.xyz` | `litessl` | **cloudflare** | **1panel** | RSA2048 |
另有 4 条「过期预警」工作流(`0 12 * * *`,纯监控发信):
优世界 / 伍比贰 / 优世界artalk评论 / 团团。
> ★ 与你的要求完全吻合:**多吉云 + 1Panel**。又拍云本来就没在这套工作流里。
> ★ `tencentcloud-eo`(腾讯云边缘安全加速)是 t-t.live 独有的第 3 个部署目标,
> 函数版第一版可不做(保持 1Panel 即可)。
### 1.2 1Panel 站点证书实况
| 站点 | 到期 | SAN | 签发方 |
|---|---|---|---|
| `artalk/openlist/openwrt/wifi/writeapi.usj.cc`、`vaultwarden` | Dec 7 2026 | `usj.cc,*.usj.cc` | LiteSSL (TrustAsia) |
| `certd/pl/pwd/www.t-t.live`、`t-t.live` | **Dec 27 2026** ✅ | `*.t-t.live,t-t.live` | Let's Encrypt |
| `ssh.200181.xyz` | Nov 23 2026 | `200181.xyz,*.200181.xyz` | — |
> ★ 2026-10-06 实测修正:t-t.live 组**已续期成功**(原快照记 Oct 9,现签发的
> 新证书是 Let's Encrypt `Sep 28 → Dec 27`,三个子域共用同一张)。
> usj.cc 组确认为 **Dec 7**;`ssh.200181.xyz` 未对外暴露 443,无法从外部探测。
> 证书落地路径:`/1panel/1panel/www/sites/<域名>/ssl/fullchain.pem`
### 1.3 可复用的凭据(2026-10-06 从 certimate `access` 表导出)
| 用途 | 凭据名 | 结构 | 说明 |
|---|---|---|---|
| 多吉云 API | 多吉云账户 | `{accessKey, secretKey}` | **零新增**,已有同款 AK/SK |
| 腾讯云 DNS | 小赵腾讯云 / 团团腾讯云 | `{secretId: AKID..., secretKey}` | **CAM 密钥** → TC3-HMAC-SHA256 签名 |
| Cloudflare | CloudFare账户 | `{apiToken: ALTpx...}` | 200181.xyz 的 DNS + 部署 |
| LiteSSL | freessl.cn | `{eabKid, eabHmacKey(base64)}` | ★ **ACME EAB 凭据**,不是私有 API |
| ZeroSSL | zerossl | `{eabKid, eabHmacKey(base64)}` | 同上,可作 CA 备选 |
| 1Panel | 团团服务器面板 | `{apiKey: muuiR..., apiVersion: v2, serverUrl}` | 服务器面板 API |
| 1Panel | 本地 | `{apiKey: 41mRF..., apiVersion: v2, serverUrl}` | 本地面板 API |
| 邮件 | 小赵的邮件通知 | `{smtpHost, smtpPort:465, username, password}` | ★ SMTP,**Worker 用不了**(无 TCP) |
> ⚠️ **SMTP 在 Worker 里不可用** —— Cloudflare Workers 没有 TCP socket。
> 现有 artalk-cf worker 已改用 **Resend HTTP API**,证书管家复用同一套即可。
### 1.4 ★ 1Panel 的 IP 白名单 —— 实测**没有拦外网**
```
1panel-core 监听: *:3721 (所有接口)
外网访问测试: http://119.29.215.187:3721/api/v2/dashboard/base
→ HTTP 401(未授权,而非连接拒绝)
grep securityEntrance/allowIP → 空(未设白名单)
```
→ **Worker 直连 `119.29.215.187:3721` 技术上可行**,只需 API Key。
> 但这意味着**面板端口 3721 对公网开放**(靠 token 鉴权)。
> 方案 A(服务器侧拉取)仍更安全,见 §3.4。
---
## 二、架构设计
```
┌──────────────── Cloudflare Worker: cert-keeper ────────────────┐
│ │
│ [crons] 每天 12:00 一行:检查所有域名的证书剩余天数 │
│ ↓ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 1. 到期检查 剩余 > 30 天 → 直接退出(不打扰) │ │
│ │ 2. ACME 申请 对新域名/即将到期域名走 DNS-01 │ │
│ │ · freessl.cn API(litessl,与现状一致) │ │
│ │ 3. 得证书 → 存 KV(cert:<domain>) │ │
│ │ 4. 部署: │ │
│ │ a) 多吉云 CDN /cdn/cert/upload + bind │ │
│ │ b) 1Panel API 直连 119.29.215.187:3721 │ │
│ │ 5. 邮件通知(成功/失败) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ [HTTP 后台 /admin] ★ 全部带鉴权 │
│ —— 只读 —— │
│ GET /admin/cert/list 证书状态(剩余天数/签发方/覆盖域名)│
│ GET /admin/config/get 读当前域名配置 │
│ GET /admin/log 最近若干次执行日志 │
│ —— 写(★ 替代 certimate 的点点点界面)—— │
│ POST /admin/config/save 保存域名配置(整份 JSON,带校验) │
│ POST /admin/cert/check 手动触发检查(可指定域名) │
│ POST /admin/cert/renew 强制续期某个域名 │
│ POST /admin/cert/deploy 只重跑部署(不重新签发) │
│ POST /admin/access/save 保存/更新凭据(AK/SK 等,加密存储) │
│ GET /admin/access/list 凭据列表(**只回显名称+尾4位**) │
└─────────────────────────────────────────────────────────────────┘
```
### 2.0 ★ 配置编辑能力(为什么必须有)
certimate 的核心价值不只是「自动续期」,还有**能点点点的配置界面**。
既然要把它精简掉,这个能力必须补上,否则每次加域名/改部署目标都要去手改 KV,
比 certimate 还难用。
**设计原则:配置即数据,全部走 API 读写,配一个单页后台。**
| 能力 | 实现 |
|---|---|
| 查看/新增/删除域名 | `GET/POST /admin/config/*` 读写 KV 里的 `config` |
| 每个域名的 SAN、DNS 渠道、部署目标 | 表单编辑,保存前做** Schema 校验** |
| 凭据(腾讯云 AK/SK、多吉云、1Panel、EAB) | `POST /admin/access/save`,**加密后存 KV** |
| 手动触发(检查/续期/重部署) | 三个 POST 接口,页面上按钮直连 |
| 执行日志 | `GET /admin/log`,环形缓冲最后 N 条 |
**鉴权**:复用 artalk-cf 现成的会话令牌方案(`ADMIN_PASSWORD` + 签名 token),
后台路径挂 `/admin`,与公开接口隔离。
**凭据安全**(★ 重要):
- 存 KV 前用 `TOKEN_SECRET` 做 **AES-GCM 加密**,KV 里落的是密文
- `GET /admin/access/list` **只回显名称和尾 4 位**(如 `AKID****3f2a`),
绝不把明文吐回浏览器;要改就整条重填
- 所有写接口校验 `Origin` + 会话,防 CSRF
**配置的前向兼容**:`config` JSON 带 `version` 字段。
未来加字段(如新的 DNS 渠道)时,读取侧按 `version` 做迁移,老配置不会读挂。
> ★ 一句话:**certimate 有的编辑能力,这里一个不少;它没有的(多吉云自动部署),这里还多了。**
### 2.1 域名配置(KV 存储,可动态加域名)
```jsonc
{
"version": 1, // ★ 前向兼容:加字段时递增,读取侧按版本迁移
"notify": {
"emails": ["177018615@qq.com"],// 到期/失败提醒收件人(可在后台改)
"daysBefore": 30 // 剩余多少天开始提醒
},
"domains": [
{
"name": "usj.cc",
"san": ["usj.cc", "*.usj.cc"],
"dns": "tencent", // 引用凭据名,见下
"deploy": ["dogecloud", "1panel"],
"dogecloud_domains": ["usj.cc"], // 绑到多吉云 CDN 的域名
"1panel_sites": ["artalk.usj.cc", "openlist.usj.cc", "..."]
},
{
"name": "t-t.live",
"san": ["t-t.live", "*.t-t.live"],
"dns": "tencent",
"deploy": ["1panel"],
"1panel_sites": ["t-t.live", "www.t-t.live", "certd.t-t.live", "..."]
},
{
"name": "200181.xyz",
"san": ["200181.xyz", "*.200181.xyz"],
"dns": "cloudflare",
"deploy": ["1panel"],
"1panel_sites": ["ssh.200181.xyz"]
}
]
}
```
**凭据单独存**(KV `access:<name>`,密文),域名配置里只写**名字**引用:
```jsonc
// KV: access:tencent-usj (AES-GCM 加密后存)
{ "type": "tencentcloud", "secretId": "AKID...", "secretKey": "..." }
// KV: access:dogecloud
{ "type": "dogecloud", "accessKey": "...", "secretKey": "..." }
// KV: access:1panel
{ "type": "1panel", "apiKey": "...", "serverUrl": "http://119.29.215.187:3721" }
// KV: access:litessl (ACME EAB,不是私有 API)
{ "type": "acme-eab", "directoryUrl": "https://...", "eabKid": "...", "eabHmacKey": "..." }
```
> ★ 拆开的好处:改凭据(如换 API Key)不用碰域名配置;
> 加域名时直接复用已有凭据名,不用重复填密钥。
---
## 三、各环节实现要点
### 3.1 ACME 申请(LiteSSL / freessl.cn)—— ★ 已确认是标准 ACME + EAB
**关键发现(2026-10-06 从 certimate 凭据表导出)**:
certimate 里 `freessl.cn` 凭据的字段是 **`eabKid` + `eabHmacKey`**,
这是 **ACME 的 External Account Binding**,**不是** freessl 私有 API。
```
freessl.cn 凭据: { eabKid: "T2G-o***", eabHmacKey: "Qk9Yg***"(base64) }
zerossl 凭据: { eabKid: "I5tkZ***", eabHmacKey: "FHnpZ***"(base64) }
```
→ **说明 certimate 走的是标准 ACME 协议**(RFC 8555),只是 CA 端要求 EAB 绑定。
**这大幅降低实现难度**:
| 步骤 | 实现 |
|---|---|
| 目录 | `GET https://acme.trustasia.com/acme/directory`(LiteSSL 的 ACME 端点,需实测确认) |
| 账号 | `newAccount`,带 **EAB**:`eab = { kid, hmacKey }` |
| 签名 | **JWS + ES256/RS256**,用 WebCrypto(CF Worker 原生支持) |
| 挑战 | DNS-01(写入 `_acme-challenge.<domain>` TXT) |
| 完毕 | `finalize` → 轮询 → 下载 `fullchain.pem` |
> **好消息**:既然是标准 ACME,开源参考 `manalejandro/cf-acme`、`PIKACHUIM/CFWorkerACME`
> 可以**几乎直接复用**,主要工作变成「加 EAB 支持」+「换成 LiteSSL 目录地址」。
> 且 CA 可做成「可切换」(LiteSSL / ZeroSSL / Let's Encrypt 同一个实现,只换 directory + EAB)。
**仍需实测确认的**:LiteSSL 的 ACME directory URL。可从 certimate 的
`workflow.graphContent` 里 grep 出来(那里存了 CA 配置)。
### 3.2 DNS-01 验证
**腾讯云 DNSPod**(usj.cc / t-t.live):
```
POST https://dnsapi.cn/Record.Create # 新建 TXT
POST https://dnsapi.cn/Record.Delete # 删除 TXT
认证:login_token = "<ID>,<Token>" (dnspod 老 API,简单)
```
> 需确认 certimate 里那两条 tencentcloud 凭据用的是 **DNSPod token** 还是 **CAM 密钥**。
> 若是 CAM(TC3-HMAC-SHA256 签名),则有现成的签名实现可复用。
**Cloudflare**(200181.xyz):
```
POST /client/v4/zones/:zone_id/dns_records # 建 TXT
DELETE /client/v4/zones/:zone_id/dns_records/:id
认证:Bearer <CLOUDFLARE_API_TOKEN>(已有)
```
### 3.3 部署到多吉云 CDN
```http
POST https://api.dogecloud.com/cdn/cert/upload.json
note=<备注>&cert=<fullchain>&private=<私钥>
→ data.id(证书ID)
POST https://api.dogecloud.com/cdn/cert/bind.json
id=<证书ID>&domain=usj.cc
```
认证:**HMAC-SHA1 签 `apiPath + "\n" + body`**,与现有 `scripts/refresh_cdn.js` 完全一致 ——
**可以把它拆出来复用代码**。
> 多吉云证书 API 参数名注意:官方文档写 `privary`(疑似笔误),实际要传 `private`。
> 上手前需实测一次确认。
### 3.4 部署到 1Panel(直连 119.29.215.187:3721)
1Panel v2 API 认证:
```
1Panel-Token: md5('1panel' + <APIKey> + <unix秒>)
1Panel-Timestamp: <unix秒>
Base: http://119.29.215.187:3721/api/v2
```
**⚠️ 最大障碍:IP 白名单。**
1Panel 服务端逐层校验「时间戳 → token → **IP 白名单**」。
Cloudflare Worker 的出口 IP 是**海量动态 IP**,无法加白名单。
**可选解法(按推荐序)**:
| 方案 | 说明 | 评价 |
|---|---|---|
| **A. 服务器侧拉取**(推荐) | Worker 只存证书到 KV;服务器上一个 cron 脚本 `curl` 拉最新证书 + 重载 nginx。**不需要开放 1Panel 端口给公网** | ✅ 最安全,绕开白名单 |
| B. 逆向拿 Worker 出口 IP | 不可行(CF 出口 IP 段巨大且变动) | ❌ |
| C. 自建小代理 | 在服务器上跑一个 30 行的反代,Worker → 代理 → 1Panel | ⚠️ 等于 A 但更绕 |
| D. 关闭 1Panel IP 白名单 | 直接放开面板端口给公网 | ❌ 危险,不建议 |
> **推荐 A**:既能满足「函数版管家」的自动化意图,又不必把面板暴露到公网。
> 服务器上只需一个 `cert-sync.sh`(wg 或 SSH 进来装一次),
> 效果与 certimate 的 1panel deploy 完全等价。
### 3.5 存储与通知
KV 键位规划:
| Key | 内容 | 是否加密 |
|---|---|---|
| `config` | 域名配置 + 通知设置(含 `version`) | 否(无敏感信息) |
| `access:<name>` | 凭据(腾讯云/多吉云/1Panel/EAB) | ★ **AES-GCM 加密** |
| `cert:<domain>` | 签发的证书 `{ cert, key, expireAt, updatedAt }` | ★ **AES-GCM 加密** |
| `log:latest` | 最近 N 条执行记录(环形缓冲) | 否 |
- **加密**:用 `TOKEN_SECRET` 派生密钥,`crypto.subtle` 做 AES-GCM。
私钥落到 KV 必须是密文 —— 万一 KV 泄露不至于直接失守。
- **邮件**:Worker 无 TCP socket,**不能直连 SMTP**。
现有 worker 已用 **Resend**(HTTP API),复用同一套:
`POST https://api.resend.com/emails`,收件人从 `config.notify.emails` 读(后台可改)
- **crons**:`[triggers] crons = ["30 4 * * *"]`(UTC 4:30 = 北京 12:30)
---
## 四、落地路径(分阶段,可随时叫停)
### Phase 1 —— 只读监控 + 配置后台(零风险)
- 部署 Worker,`crons` 每天检查三个域名的证书剩余天数
- 到期 < 30 天 → 发邮件提醒
- **★ 同时把配置后台做出来**(`/admin` 单页):
- 证书状态列表(剩余天数、签发方、覆盖域名)
- 域名配置的**增删改**(写 KV,但 Phase 1 不参与签发)
- 凭据管理(加密存 KV,只回显尾 4 位)
- **完全不签发、不部署**,纯观察 + 配置。可以先跑两周验证数据准确性。
> ★ 把后台放 Phase 1 的理由:它是**纯读写自己的 KV**,不碰任何外部系统,
> 零风险却能立刻替代 certimate 的日常操作(加域名、改收件人、看状态)。
> 等工作流验证完毕,Phase 2 再让它「动起来」。
### Phase 2 —— 接管续期(+ 多吉云自动部署)
- 实现 ACME(Let's Encrypt 标准协议优先)
- DNS-01 走腾讯云 + Cloudflare
- 自动部署到**多吉云 CDN**(有正规 API,风险低)
- 邮件通知结果;**1Panel 仍由 certimate 管**
- 后台新增「手动续期 / 只重部署」按钮
### Phase 3 —— 接管 1Panel + 关停 certimate
- 服务器加 `cert-sync.sh`(从 Worker 拉证书 + reload)
- 观察一个完整续期周期(90 天)
- 确认无误 → **关停 certimate 容器**
---
## 五、开工前必须确认/实测的 4 件事
1. **freessl.cn API 是否可用**(认证 + 申请 + 下载全流程)
→ 若不行,改 **Let's Encrypt 标准 ACME**(推荐这条路,更稳)
2. **certimate 里 tencentcloud 凭据的类型**
(DNSPod token 还是 CAM 密钥?决定签名实现)
3. **多吉云 `cdn/cert/upload.json` 的参数名**(`private` 还是 `privary`)
4. **1Panel 侧走「服务器拉取」还是「直连面板」**(强烈建议前者)
---
## 六、现成可参考的开源实现
| 项目 | 用途 |
|---|---|
| `manalejandro/cf-acme` | CF Workers + KV,用 WebCrypto 实现标准 ACME,DNS-01 自动申请 |
| `PIKACHUIM/CFWorkerACME` | CF Worker 上的 SSL 申请代理,支持 LE / ZeroSSL / GTS |
| `Menci/deploy-certificate-to-upyun` | 又拍云部署(证明其**无公开 API**,故本方案放弃又拍云) |
| 本项目 `scripts/refresh_cdn.js` | 多吉云 HMAC-SHA1 签名,**直接复用** |
---
## 七、与 certimate 的能力对照
| 能力 | certimate(现状) | 函数版管家 |
|---|---|---|
| 申请证书 | ✅ | ✅ |
| DNS-01 | ✅ 腾讯云 + CF | ✅ 同 |
| 部署多吉云 | ✅ | ✅ |
| 部署 1Panel | ✅ | ✅(走服务器拉取) |
| 部署又拍云 | ❌(本来就没配) | ❌(放弃) |
| **加/改域名** | 网页点点 | ✅ **网页点点**(`/admin` 后台,见 §2.0) |
| **改凭据 / 通知收件人** | 网页点点 | ✅ **网页点点** |
| **看执行日志** | ✅ | ✅(`GET /admin/log`) |
| 手动触发续期/重部署 | ✅ | ✅(后台按钮) |
| 凭据存储 | 明文 SQLite | ★ **AES-GCM 加密** |
| 需要服务器 | **✅ 需要** | ❌ **不需要** |
| 通知 | 邮件 | 邮件(Resend HTTP) |
| 成本 | 服务器 | **0**(CF 免费额度) |
> ★ 结论:**certimate 的编辑能力一项不少**(加域名、改凭据、看日志、手动触发),
> 而且额外多了「凭据加密存储」和「多吉云自动部署」。
> 唯一少的是 certimate 的「可视化工作流编排」(拖拽节点那种)——
> 本项目只有 3 个固定工作流,用配置表单比拖拽更直接,不构成损失。
---
## 八、签发/续期落地实现(2026-10-06 完成并上线)
### 8.1 模块划分(全部纯 WebCrypto,零 npm 依赖)
| 文件 | 职责 | 参照 certimate |
|---|---|---|
| `src/lib/acme.ts` | ACME v2 客户端:ES256 JWS、RFC7638 thumbprint、EAB、badNonce 重试、DNS-01、手写 DER 的 CSR | `internal/certacme` |
| `src/lib/dnsprovider.ts` | DNS-01 适配:DNSPod(TC3-HMAC-SHA256)、Cloudflare | `pkg/sdk3rd/*` |
| `src/lib/deployer.ts` | 部署适配:多吉云 CDN、1Panel 站点(幂等换证书) | `deployers/*` |
| `src/lib/certissue.ts` | 编排:探针判天数 → 账户注册/复用 → 签发 → 落库 → 逐目标部署 | `workflow` |
### 8.2 与 certimate 的关键差异:探针优先
certimate 每个 workflow 挂 cron,**每天无条件重跑整条流水线**。
本项目改为:**先探针查证书剩余天数,低于 `RENEW_BEFORE_DAYS`(30 天)才真正签发**。
好处:省 CA 限速额度(Let's Encrypt / LiteSSL 都有周限额)、DNS 改动更少、日志只有真实动作才有记录。
### 8.3 免费版 CPU 限制(★ 结论已在 §9 被实测推翻,保留原文供对照)
| 项 | 免费版 | 付费版($5/月) |
|---|---|---|
| Worker CPU | **硬顶 10ms** | 默认 30s,可调至 5min |
| `[limits] cpu_ms` 配置 | ★ **直接拒绝部署**(code 100328) | 支持 |
| ACME 签发 | ⚠️ 擦边(见 §9.2 实测) | ✅ 稳 |
> ★★ **更正(2026-10-06 晚)**:这里原先写「❌ 跑不起来」是**按文档推断、未实测**得出的,
> 属于误判。实测密码学开销只有 0.69~3.3ms,是「擦边」而不是「必然爆」。
> 最终决策与实测数据见 **§9**。
实测报错:
```
X [ERROR] A request to the Cloudflare API (.../workers/scripts/artalk-cf/versions) failed.
CPU limits are not supported for the Free plan. [code: 100328]
```
注意是**整个部署失败**,不是「忽略该字段」——所以 Free plan 下 `[limits]` 必须保持注释。
(探针 / 环境自检 / 手动查询这些轻量功能在免费版完全可用。)
HTTP 请求 duration 本身**无硬限制**,签发的耗时主要花在 `sleep` 等 DNS 传播 + 轮询上,
**不计 CPU**——所以升级 Paid 后,这套架构是成立的。
### 8.4 实测踩坑(易误判,勿回退)
- **多吉云 `bind` 参数是 `{id, domain}`**,不是 `cert_id`。用假 id 999999 做对照实验确认:
`cert_id` 回「域名不存在」(参数被无视)、`id` 回「指定证书不存在」(参数生效)。
- 多吉云上传私钥字段名是 **`private`**(`pri`/`key`/`privateKey` 均报「私钥格式错误」);
列域名用 `/cdn/domain/list.json`(`/cdn/domain.json` 回 `400 domain 格式错误`)。
- **LiteSSL ACME 目录必须带 `/v2`**:`https://acme.trustasia.com/acme/v2/directory`
(少 `/v2` 直接 404);`meta.externalAccountRequired: true` 强制 EAB。
- **1Panel 必须用 `/api/v2/`**。v1 的表现极具欺骗性:**HTTP 200 但正文是 HTML 停用页**
(`Access Temporarily Unavailable`)——看似限流,实为 v1 已停用。
- **1Panel HTTPS 配置字段是 `SSL`(大写)**,写错不报错,只是 `cur?.ssl?.id === sslId` 永假
→ **每次续期都重绑、短暂 reload nginx**。
- **CSR 的 `extensionRequest` 只能套三层 SEQ**:`a0 <len> / 30 / 06 09…090e / 31 …`
多套一层会让 OpenSSL 拿 SAN 的 OID 当 attribute type 查表,报
`wrong tag ... Field=object, Type=X509_ATTRIBUTE`(这个 bug 会让所有签发失败)。
### 8.5 上线记录
- Worker version_id `1899e229-ca59-486a-a758-a6e2cbec0919`,路由 `api.200181.xyz`
- cron 三条:`17 3 * * *`(评论 GC)、`0 * * * *`(RSS)、`10 4 * * *`(**证书续期**)
- KV 已写入 8 条:7 条凭据(AES-GCM)+ 1 条 `certkeeper:config`(3 组域名、阈值 30 天、收件人)
- 新路由已注册上线的**铁证**:`GET /api/v2/ssl/renew-check` → **403**(鉴权拦截),
而瞎编路径 `/api/v2/ssl/__nope` → **404**。403 vs 404 是最干净的「路由存在性」证明。
### 8.6 待办
- **1Panel API 白名单**:代码侧已按官方 SDK 写好,但线上调用被「Access Temporarily Unavailable」拦。
1Panel 开了「安全登录」(入口 `/project`),需在 **面板 → 设置 → API 接口**确认已启用,
并把 Cloudflare Worker 出口 IP 加入白名单,否则 1panel 部署目标会失败。
(环境自检 `/ssl/selfcheck` 会如实报出这一点。)
- **Cloudflare DNS 凭据缺口**:现 `cloudflare` token 是 Workers/KV/D1 专用,
`GET /zones` 回 200 + 空数组、`dns_records` 直连 403 → **`200181.xyz` 无法走 DNS-01**。
需到 Cloudflare 重新生成含 `Zone → DNS → Edit` 且覆盖 `200181.xyz` 的 token。
- **真实端到端签发验证**(需 Paid plan + 用户批准):会真写 DNS TXT、消耗一次 CA 配额、重绑站点。
---
## 九、★ 架构定案:签发链路搬到国内机(2026-10-06 晚)
### 9.1 决策:B + C 组合,拒绝 CF 付费
用户拍板:**不买 CF Paid($5/月 ≈ ¥36/月)**,改走组合方案:
| 方案 | 内容 |
|---|---|
| **B** | Worker 留在**免费版**,代码优化(缓存 CryptoKey,把 CPU 压进 10ms) |
| **C** | **「签发 + 部署」整条链路搬到国内机的 Docker 容器**里 |
理由:¥36/月 已经比这台本来就在跑的国内机(腾讯云广州)贵,且国内机本来就常驻、
Docker 管理成本几乎为零,没必要为一道 10ms 的紧箍咒额外付费。
> 附带收益:国内机能**直接访问 1Panel API 与各站点探针**,绕开了 Worker 最大的两个坑
> ——「CF 出口 IP 拿不到 1Panel 白名单」与「Worker 无 TCP 探针」。
> 实测国内机探针能直接读到证书正文(Worker 做不到):`usj.cc` 62 天 / `t-t.live` 83 天 /
> `200181.xyz` 62 天。
### 9.2 ★ 实测数据(推翻 8.3 的旧结论)
本机 Win32 上 `process.cpuUsage()` 采样粒度太粗(200ms 忙循环只报 46ms),且
`crypto.subtle` 走线程池不计入主线程 —— **必须改用「大循环 + `process.hrtime.bigint()` 墙钟」**。
| 操作 | 耗时 |
|---|---|
| EC P-256 keygen | 83 µs |
| ECDSA sign + SHA256 | 41 µs |
| SHA-256 digest | 3.2 µs |
| `importKey('jwk')` | **126 µs** |
| `sign`(已缓存 CryptoKey) | **84 µs** |
| `importKey` + `sign`(未缓存) | **252 µs** |
一次签发约 10~12 次 JWS:**优化前 ≈3.3ms → 优化后 ≈1.4ms**(乘 2~3 倍保守系数:
6.6~10ms vs 2.8~4.2ms)。免费版 10ms 是「能压进去但没余量」。
### 9.3 优化:acme.ts 缓存 CryptoKey
`src/lib/acme.ts` 原实现**每次 JWS 都 `importKey`**。改为缓存 Promise:
```ts
private signingKey: Promise<CryptoKey> | null = null;
private importSigningKey(): Promise<CryptoKey> {
if (!this.signingKey) {
this.signingKey = (crypto.subtle.importKey(
'jwk', this.account.jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'],
) as Promise<CryptoKey>).catch((e) => { this.signingKey = null; throw e; });
}
return this.signingKey; // 存 Promise,并发时只 import 一次
}
```
> 存 **Promise** 而非 CryptoKey:并发调用时只真正 import 一次;
> 失败清缓存避免永久 reject。实测:单次签发 importKey **12 → 1 次**。
### 9.4 新增部署单元 `deploy/cn-certkeeper/`(Docker 管理)
| 文件 | 作用 |
|---|---|
| `Dockerfile` | `node:22-alpine` + 阿里云 apk 源 + `ca-certificates tzdata`;`COPY lib/` + `COPY src/` |
| `docker-compose.yml` | 只绑 **`127.0.0.1:8019`**;`TZ=Asia/Shanghai`;`HOST=0.0.0.0`(容器内必须,否则端口映射进不来) |
| `src/kv-file.mjs` | **文件系统版 KVNamespace**(照 KV 接口面:`get/put/delete/list`),键位 `a:b:c` → `<dir>/a/b/c.kv`;`put` 先写 `.tmp-<pid>` 再 `renameSync`(原子,防半截密文) |
| `src/serve.mjs` | 服务本体:`withLock()` 并发闸 + `scheduleDaily()` 自递归 setTimeout + 带鉴权 HTTP 接口 |
| `lib/*` | 直接复用 `blog-admin/src/lib/` 编译产物(acme/deployer/dnsprovider/certissue/certvault…) |
**接口**:`/health`(免鉴权) `/status` `/renew`(POST) `/renew-check` `/preflight` `/log` `/data`
**关键设计**:
- **同一把 `TOKEN_SECRET`** → certvault 的 AES-GCM 密文两边可互解,Worker 与国内机共享 KV 数据
- `acme.ts` **只用 Node 20+ 全局 API**(`crypto`/`fetch`/`btoa`/`Request`/`Response`/`TextEncoder`)
→ **可直接跑在 Node,无需改代码**
- `preflight` 逐目标体检,**必须读 `ping()` 返回的 `{ok,error,hint,detail}` 对象**,
不能当字符串(否则显示 `[object Object]`,更糟的是把 `ok:false` 记成通过)
### 9.5 部署与验收记录
- 镜像 `cn-certkeeper:local`(241MB),容器 `Up (healthy)`,`127.0.0.1:8019`
- 数据目录 8 个 `.kv` 就位(7 凭据 + 1 config)
- **容器内 preflight 7/7 全过**:litessl/zerossl 目录可达 + EAB 正确 · tencent-usj/tencent-tt 可读 ·
cloudflare→200181.xyz 可读 · dogecloud 3 个 CDN 域名 · **1panel-cn API 可达**(容器访问 1Panel 未被白名单挡)
- **本地端到端 mock 测试 22/22 全过**(FileKV + 凭据解密 + ACME 注册 + 签发 + DNSPod 写/删 TXT + 落库 + 日志)
- 定时:`RENEW_HOUR=4` / `RENEW_MINUTE=10`(对齐原 Worker cron `10 4 * * *`)
### 9.6 ★ 清理 1Panel 过期垃圾证书(2026-10-06)
删掉 6 条 certimate 早期遗留的过期证书(**删除前已导出全量记录含私钥**到
`secrets-backup/1panel-ssl-backup-<ts>.json`,可回滚):
| id | 域名 | 到期 |
|---|---|---|
| 4 | 200181.xyz | 2026-05-24 |
| 5 | \*.usj.cc | 2026-05-11 |
| 6 | \*.usj.cc | 2026-07-12 |
| 7 | \*.200181.xyz | 2026-07-24 |
| 8 | \*.usj.cc | 2026-09-11 |
| 9 | 200181.xyz | 2026-09-24 |
**保留(在用)**:`id 11 usj.cc`(5 站)· `id 10 200181.xyz`(ssh.200181.xyz)· `id 2 *.t-t.live`(5 站)。
结果:**证书库 9 条 → 3 条**,所有站点 HTTPS 正常。
> 判据用**证书记录自带的 `websites` 字段**(1Panel 自己算的引用关系),
> 比遍历 `/websites/{id}/https` 更权威。删除接口 `POST /websites/ssl/del {"ids":[...]}`。
>
> ⚠️ 3 个手工建的 nginx vhost(`dnsapi.usj.cc` / `writeapi.usj.cc` / `vaultwarden`)
> **不归 1Panel 站点管理**,证书是**文件拷贝**(指纹与 id 11 一致),与证书库记录解耦。
### 9.7 ★ 未决冲突:t-t.live 的「两套管理」 → 已定案:本项目接管
- 1Panel 证书库 `id 2 *.t-t.live` 记录的是**旧证书**(到期 2026-10-09)
- 但对外 **实际服务**的是 certd 直接写进 nginx 的**新证书**(Let's Encrypt,到期 2026-12-27)
- → certd **绕过 1Panel 证书库**改 nginx。若本项目也部署 t-t.live 的 1panel 目标,
会把 1Panel 库里的旧证书物化到站点 `ssl/`,**覆盖掉 certd 的新证书**。
**必须二选一**:
1. 停掉 certd,本项目接管 t-t.live 全部(含 1Panel + 腾讯云 EO)
2. 本项目**放弃 t-t.live 的 1panel 部署目标**,只留 usj.cc / 200181.xyz
> **2026-10-06 晚定案:选 ①** —— 停掉 certd,本项目接管 t-t.live 全部。
### 9.7b ★ 接管时查清的真实现状(推翻 9.7 的部分描述)
实地排查后,事实和上面的推测**不一样**,记下来免得后人再绕:
| 项 | 原先以为 | **实测真相** |
|---|---|---|
| 谁在管 t-t.live | 一个 `certd` | 这台机器上**没有 certd 进程/容器**;真正的竞争者是 **certimate 容器**(`1Panel-certimate-OKCO`,7 个工作流每天 12:00 起跑) |
| `certd.t-t.live` 是什么 | certd 本体 | 只是个 nginx vhost → `127.0.0.1:6000` → **frps** 隧道到别处;`6000/7000` 是 frp 端口,与证书无关 |
| 源站证书到期 | 2026-12-27 | **2026-10-09(只剩 3 天)**。5 个站点 `ssl/` 目录里的文件 mtime 全是 `2026-07-11 21:28:03`,之后再没被更新过 |
| 公网看到的证书 | = 源站证书 | **不是**。公网走**腾讯云 EO 边缘加速**(apex 是 CNAME → `t-t.live.eo.dnse2.com`),边缘那张是 LE **12-27** |
### 9.7c ★★ 根因:apex 上的 CNAME 让 lego 拿不到权威 NS
certimate 的「团团证书申请工作流」**2026-10-06 04:05 执行失败**(usj.cc / 200181.xyz 同日都成功):
```
failed to obtain certificate: resolver: one or more domains had a problem:
[*.t-t.live: dns01: time limit exceeded: last error:
authoritative nameservers: [zone=t-t.live.] could not determine authoritative nameservers]
```
实测 `dig`:
```
t-t.live. 60 IN CNAME t-t.live.eo.dnse2.com. ← apex 是一条 CNAME
dig +short NS t-t.live → t-t.live.eo.dnse2.com. ← 问 NS 也回 CNAME!
```
`t-t.live` 用了腾讯云 EO 的 **“CNAME 接入”**(apex 直接 CNAME 到 EO),
DNSPod 的权威服务器对 apex 的 **任何**查询都返回那条 CNAME。
lego 的 `FindZoneByFqdn` 是「从 `_acme-challenge.t-t.live` 往上问 NS」——
走到 apex 拿到的是 CNAME 而非 NS,于是判定「拿不到权威 NS」→ 传播检查超时 → 放弃。
> `usj.cc`(NS = `duncan.dnspod.net` / `brady.dnspod.net`)和
> `200181.xyz`(NS = `jobs/martha.ns.cloudflare.com`)的 apex 都没有 CNAME,
> 所以它们从没撞上这个问题 —— 这也解释了「为什么只有 t-t.live 年年失败」。
★ **我们的实现天然不受影响**:`acme.ts` **不做 NS 查询**,而是
「用 DNSPod API 写字面量 `_acme-challenge.<domain>` → 固定等 30s → 通知 CA 校验 → 轮询」。
CA 自己查 TXT 时走的是正常解析路径(`_acme-challenge` 是独立名字,不被 apex 的 CNAME 影响)。
### 9.7d ★ 附带发现:探针必须量「源站」而不是「公网」
`certprobe.probeTls` 原本只按域名做公网握手。对挂在 CDN / 边缘加速后面的域名,
量到的是**边缘证书** —— 后果分两种,都很坏:
| 域名 | 公网探到 | 源站实际 | 后果 |
|---|---|---|---|
| `t-t.live` | 83 天(EO 的 LE 证书) | **4 天** | 续期判定**永远**「还很新」→ 源站证书悄悄过期,站点挂掉 |
| `200181.xyz` | 62 天(Cloudflare 代理证书) | 48 天(1Panel,TrustAsia) | 续期被推迟约 2 周,余量被吃掉 |
| `ssh.200181.xyz` | **DNS 不解析**(无公网 A 记录) | 48 天 | 探针报 `ENOTFOUND` → `daysLeft=null` → 判「必须续」→ **每天尝试续期** |
**修复**:给 `DomainConfig` 加两个字段,属**可选、向后兼容**(不填行为不变):
| 字段 | 作用 |
|---|---|
| `probe_connect` | 探针**连到哪个地址**(填源站 IP)。SNI 仍是域名,所以源站 nginx 能按 `server_name` 选到对的 vhost |
| `probe_sni` | 探针发出去的 **SNI**。源站上未必有与域名同名的站点 —— 例如 1Panel 里**没有** `usj.cc` 这个网站(它只是证书名),直接拿 `usj.cc` 当 SNI 会落到默认 server、拿到别的证书 |
三个域名都配上了源站直连:
```jsonc
usj.cc { probe_connect: '119.29.215.187', probe_sni: 'artalk.usj.cc' } // 面板无 usj.cc 站点
t-t.live { probe_connect: '119.29.215.187', probe_sni: 't-t.live' }
200181.xyz { probe_connect: '119.29.215.187', probe_sni: 'ssh.200181.xyz' } // 面板站点名是子域
```
实测(本机真 Node 跑编译产物):
```
t-t.live 公网 → 83 天 / 源站 → 4 天 ← 修复生效,差 79 天
artalk.usj.cc 公网 → 62 天 / 源站 → 62 天 ← 本来就对
ssh.200181.xyz 公网 → 解析失败 / 源站 → 48 天 ← 修复了「天天误判要续期」
```
同一能力也补进了国内机 `editor-api` 的 `/ssl-probe`(新增 `&connect=<IP>` 参数),
这样 Worker 侧的**只读**页面也能显示正确的剩余天数。
### 9.8 待决 → 2026-10-06 晚**全部定案**
| 事项 | 定案 |
|---|---|
| Worker 的续期 cron `10 4 * * *` | **停掉**。`wrangler.toml` 的 crons 改为 `["17 3 * * *", "0 * * * *"]`;`index.ts` 里整个 renew 分支删除 |
| Worker 的签发入口 `POST /ssl/issue` | **改成 501 硬拒绝**,响应里给出「去国内机执行」的 curl 命令;后台按钮文案改成「签发/续期(在国内机)」 |
| `usj.cc` 的 SAN 升级为 `usj.cc;*.usj.cc` | **接受**(线上原本是单名 `CN=usj.cc`,升级后一张证书同时覆盖主域与全部子域) |
| `dnsapi.usj.cc` 上 HTTPS | 仍未做;`cn-certkeeper` 只绑 `127.0.0.1:8019`,暂不走反代 |
| 停 certd / 接管 t-t.live | **certd 根本不存在**,真正的竞争者是 certimate 容器;处理方式见 §9.10 |
### 9.9 ★★ 上线前修掉的两个 ACME 客户端真实 bug(只有打真 CA 才会现形)
这两个 bug 从写完到上线一直藏着,原因是:**账户注册从来没成功过**
(`certkeeper:acme-account` 这个键一直是空的),所以协议层代码**一行都没真跑过**。
本地自测 117 项全过 —— 因为它们覆盖的是鉴权矩阵 / 配置校验 / 加密往返 / 脱敏,
**不覆盖 ACME 协议交互**。
#### Bug ① `protected.jwk` 里带着私钥 → 403 `newAccount JWS signature is invalid`
| | |
|---|---|
| 症状 | LiteSSL `newAccount` 回 `403 {"detail":"newAccount JWS signature is invalid"}` |
| 迷惑点 | **本地验签是通过的** —— 拿 JWK 的 x/y 造公钥验 raw `r‖s`,结果 `true`。报错文案把人往「签名算法错了」上带 |
| 真因 | 账户密钥是 `exportKey('jwk', privateKey)` 出来的,含 `d` / `key_ops` / `ext`。我们把它**原样**塞进了 `protected.jwk`:<br>`{"key_ops":["sign"],"ext":true,"kty":"EC","x":"…","y":"…","crv":"P-256","d":"…"}` |
| 规范 | RFC 8555 §6.2:`jwk` 字段必须是**公钥**;§7.3.4:EAB 内层 payload 同样是公钥;RFC 7517 §6.2.1:EC 公钥**只**定义 `crv/kty/x/y` |
| 修复 | 新增 `publicJwk()`,把 JWK 裁剪成该密钥类型定义的成员;`protectedHeader()` 与 EAB 的 `innerPayload` 都走它 |
| 附带 | 这也是**安全修复** —— 私钥标量 `d` 不该发给 CA |
> 排查手法:写了个自包含脚本,走**真实编译产物**的代码路径生成 JWS,
> 再**用公钥本地验签**。本地过 → 密码学没错,问题在 JWK 的语义/内容。
> 这一步把「签名算法错」和「JWK 内容不被接受」一刀切开,省掉大量瞎猜。
#### Bug ② POST-as-GET 的「空 payload」被编成了 `IiI` → 400 `Expected JWS payload message`
| | |
|---|---|
| 症状 | LiteSSL 读账户资源 / 授权 / 订单时回 `400 {"detail":"Expected JWS payload message"}` |
| 真因 | `postAsGet()` 调 `post(url, '')` → `b64uJson('')` = base64url(JSON 的 `""`) = **`IiI`**,服务端解出来是两个字符 `"`,不是空 |
| 规范 | RFC 8555 §6.3:POST-as-GET 的 payload 必须是**零长度的八位字节串**。既不是 JSON `null`(那是「让服务端删字段」),也不是 JSON `""` |
| 修复 | `postAsGet()` 改成传 `undefined`;`signJws()` 里 `payload === undefined` 走空串分支(`p = ''`) |
| 为什么以前没炸 | **ZeroSSL 容忍 `IiI`**(实测 POST-as-GET 回 200 valid),LiteSSL 严格。换 CA 才把它照出来 |
修复后实测:`postAsGet` 发出去的 `payload = ""`,解码长度 **0 字节**。
### 9.10 ★★ LiteSSL 账户接管(EAB 是一次性的)
#### 两个事实先纠正
1. **目录地址错了**。我们配的是 `https://acme.trustasia.com/acme/v2/directory`,
而 certimate 数据库里真实用的是 **`https://acme.litessl.com/acme/v2/directory`**。
打错的目录会回 `403 externalAccountBinding kid is not valid` ——
**EAB kid 是绑定到具体 CA 目录/账户的**,地址不对,kid 自然不认。
(另有两种写法 `…/v2/DV90/directory` 是 CertCloud 系的,走 404。)
2. **EAB 只能用一次**。换到正确目录后回的是
`400 externalAccountRequired {"detail":"External account binding has already been used"}`
—— certimate 在 2026-02 已经用这条 EAB 注册过账户了。
#### 处置:继承 certimate 的账户(而不是再要一条新 EAB)
「接管」最干净的做法就是把**它已经注册好的账户过户过来** —— 同一个 CA 账户,
不必打扰用户去 FreeSSL 控制台新建 EAB。
从 certimate `data.db` 的 `acme_accounts` 表取(`ca='litessl'` 只有一条):
```
kid = https://acme.litessl.com/acme/v2/acct/NjAuzljPMeTgYARuIduk3w
私钥 = -----BEGIN EC PRIVATE KEY-----(SEC1,P-256)
email = imql@qq.com
```
过户步骤(都跑在国内机):
1. 宿主机 `python3` 抽 `data.db` 的 `acme_accounts` 行 → `/tmp/litessl-acct.json`(600)
2. `docker cp` 进容器,容器里 `createPrivateKey(pem).export({format:'jwk'})` 转 JWK
3. 写 `certkeeper:acme-account`(= `{jwk, kid, directoryUrl}`,就是 `getAcmeAccount` 认的键位)
4. **顺带修 `litessl` 凭据的 `directoryUrl`** 为正确地址 ——
否则 `getAcmeAccount` 里 `saved.directoryUrl === directoryUrl` 判不等,会去重新注册
5. 清掉宿主机/容器里的私钥临时文件
验收:
```
② 复用已存账户:kid = https://acme.litessl.com/acme/v2/acct/NjAuzljPMeTgYARuIduk3w
③ 用 kid 签名访问账户资源:HTTP 200,status=valid
```
> 为此给容器加了个常驻诊断工具 **`deploy/cn-certkeeper/src/selfcheck-acme.mjs`**:
> 只跑「读目录 → 注册/复用账户 → POST-as-GET 验 kid」,**不签发、不写 DNS、不部署**。
> 以后再遇到「续期不动」,先跑它,一刀切开是 CA 账户层还是 DNS/部署层。
> ```bash
> docker exec cn-certkeeper node src/selfcheck-acme.mjs --reuse # 验已存账户
> docker exec cn-certkeeper node src/selfcheck-acme.mjs --ca zerossl # 换 CA 对照
> ```
### 9.11 ★★ 定案:`ssl/update` **不物化**站点文件,`ssl/upload`+`sslID` 才会
`OnePanelDeployer.deploy()` 的幂等捷径是:读 `/websites/{id}/https`,
若 `cur.enable && cur.sslId === sslId` 就**跳过绑定**(避免多余的 nginx reload)。
风险在于:站点 `ssl/{fullchain,privkey}.pem` 是 1Panel 从**证书库**物化出来的,
如果「改记录内容」**不会**重新物化,那么**第二次续期就会静默失败** ——
库里证书是新的,站点文件还是旧的。
#### 实测(`id=13`,5 个 t-t.live 站点)
| 调用 | 返回 | 5 个站点 `ssl/*.pem` 的 mtime / md5 |
|---|---|---|
| `POST /websites/ssl/update`(证书内容原样回传) | **200 success** | **一个都没变** |
| `POST /websites/ssl/upload` + `sslID=13`(>0) | 200 success | **全部刷新** |
结论:**1Panel 的 `ssl/update` 会静默空转** —— 只改库记录的元数据,
不碰站点目录下的 PEM。空转还不可见,因为接口返回 `success`。
#### 三个接口的正确用途(别混)
| 接口 | 用途 | 陷阱 |
|---|---|---|
| `POST /websites/ssl/update` | 改 **ACME 申请设置**(domains / autoRenew / DNS 账户) | 结构体 `WebsiteSSLUpdate` **没有** `certificate`/`privateKey` 字段,传了被静默丢弃;`domains` 只从 `otherDomains` 取,不传就**清空**;还会顺带把 `autoRenew` 置 false |
| `POST /websites/ssl/upload` + `sslID > 0` | **换证书内容**(原地更新) | 走 `Upload()`:`SSLID>0` → 取记录 → 用 `PrivateKey`/`Certificate` 覆盖 → **重算** `ExpireDate/StartDate/Type/PrimaryDomain/domains` → `UpdateSSLConfig()` → 重新物化站点文件 |
| `POST /websites/ssl/upload`(不带 `sslID`) | 新建一条记录 | 会造重复记录 |
★ 副作用:`Upload()` 把 `primaryDomain` 重算成**证书的第一个 SAN**(`cert.DNSNames[0]`)。
SAN 顺序一变 `primaryDomain` 就漂移(`#11` 从 `usj.cc` 变 `*.usj.cc`)
→ 匹配记录时**必须补 `domains` 兜底**,否则每次续期都新建一条重复记录。
#### 落地改动
`deployer.ts`:
- `uploadSsl()` 改用 `ssl/upload` + `sslID` 原地更新,返回 `{ id, replaced }`;
旧记录的 `description` 带回,新建后回查库补 id
- **新增 `matchSslRecord<T>()`**:两级匹配(`primaryDomain` 优先,`domains` 兜底),多命中取到期最晚
- `deploy()` 幂等判定加 `!replaced`;`replaced` 为真时**强制重绑**以刷新 `ssl/` 文件:
```ts
if (cur.enable && cur.sslId === sslId && !replaced) { /* 跳过 */ }
if (replaced && cur.enable && cur.sslId === sslId) {
log(`1Panel:网站 ${key} 指向的证书 #${sslId} 内容刚被更新,强制重绑以刷新 ssl/ 文件`);
}
```
### 9.12 ★★ 本轮又修掉的 5 个静默失败(2026-10-06 深夜)
这五个的共同特征:**接口返回 200 / 逻辑不报错,但结果没生效**。
全部只有「真打线上、真去比对产物」才会现形。
#### ① `POST /websites/{id}/https` 的字段名是 `websiteSSLId`,不是 `sslId`
| | |
|---|---|
| 症状 | 换证书时回 `HTTP 200 + code 500「服务错误: record not found」`,**整个 t-t.live 部署被阻断** |
| 迷惑点 | 报错文案是 DB 层(`record not found`),完全指不到参数上 |
| 真因 | `dto/request/website.go` 里 `WebsiteHTTPSOp{ WebsiteSSLID uint \`json:"websiteSSLId"\` }`。发 `sslId` → Go **静默忽略** → 零值 `0` → `websiteSSLRepo.GetFirst(WithByID(0))` → not found |
| 验证 | 对照实验:`sslId=13 → 500` / `websiteSSLId=13 → 200 code=200` |
| 修复 | 改字段名 |
#### ② `ssl/update` 换不了证书内容 → 见 §9.11
#### ③ 多吉云「复用」判据只比域名集合 → 续期静默空转
| | |
|---|---|
| 症状 | 明明刚签了新证书,多吉云那边**不复用**,每次都重传;反过来更糟的情况是「第一次之后再不复用新证书」 |
| 真因链 | 多吉云 list 接口**不返回 PEM**,只能比域名集 → 只看域名集就永远认为「已覆盖」;但我们的 `cert.notAfter` 因为 ④ 退化成了「签发时刻 + 90 天」,与对方 `expire` 差 1~2 小时,比对永远为假 |
| 修复 | ① `parsePemInfo` 修好(见 ④);② 判据带上**到期时间**,放 1 天容差:<br>`return theirs > 0 && theirs >= cert.notAfter - 86_400_000;` |
| 验证 | `多吉云:已有覆盖 usj.cc, www.usj.cc, artalk.usj.cc 的证书 #41963(到期不早于本次),复用` |
#### ④ `parsePemInfo()` 对「完整链」返回 `{}`
| | |
|---|---|
| 症状 | `rec.expireAt` 静默退化成调用方兜底值「签发时刻 + 90 天」 |
| 真因 | 旧实现把 PEM 里**各段 base64 拼接**后一次 `atob`。**中间段尾部的 `=` 填充**出现在字符串中段 → `atob` 抛错 → 整函数返回 `{}`。而完整链(叶 + 中间)恰好是部署器最常拿到的形态 |
| 修复 | 只取**第一段**(叶证书)解析:`blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)`,`blocks[0]` 解不出来就 `return {}` |
| 附带 | 新增 `derLen()` / `parseSanFromDer()`:精确定位 SAN 扩展 OID `2.5.29.17` 再读 `[2] dNSName`,替掉原来的字节扫描启发式 |
| 回归 | 自测新增 **[11] 节 8 项断言**,样本是 openssl 现场生成、写死内联 base64 的叶+中间证书(**两段都以 `=` 结尾,正是 bug 现场**),含精确值断言 `notAfter === 1799063386000`、`SAN === ["leaf.test.example","*.leaf.test.example"]`、以及「链解析不混入中间证书主体名」 |
#### ⑤ `400 authorization must be pending` —— 是竞态,不是逻辑错
| | |
|---|---|
| 症状 | DNS-01 挑战通知偶尔回 `400 authorization must be pending`(200181.xyz 上连中两次) |
| 试过没用 | 「先读状态再 POST」挡不住**毫秒级窗口** —— 读的时候还是 pending,POST 到的时候服务端已置位 |
| 修复 | POST 侧做**幂等容错**:只认这一句错误,吞掉后交由 `pollAuthz` 定论 |
| 验证 | 重试成功,`notAfter: 1799056799000` |
```ts
try {
await this.post(p.chalUrl, {});
} catch (e) {
const m = e instanceof Error ? e.message : String(e);
if (!/authorization must be pending/i.test(m)) throw e;
this.log(`${p.label} 通知验证时状态已翻过 pending(竞态),改由轮询定论`);
}
```
### 9.13 本轮最终状态(2026-10-06 收工)
**1Panel 证书库:5 条 → 3 条,全部在用**
| id | primaryDomain | domains | CA / 算法 | 到期 | 绑定站点 |
|---|---|---|---|---|---|
| `#13` | `t-t.live` | `*.t-t.live` | LiteSSL ECC | 2027-01-04 | 5 |
| `#11` | `*.usj.cc` | `usj.cc` | LiteSSL ECC | 2027-01-04 | 5 |
| `#10` | `*.200181.xyz` | `200181.xyz` | LiteSSL ECC | 2027-01-04 | 1 |
删掉的是 `#12`(重复空壳)与 `#2`(2026-10-09 到期的旧证书)。
**全网域只读核验**(`POST /renew-check`):
| 域名 | 探针 | subject | TLS | daysLeft | needRenew |
|---|---|---|---|---|---|
| `t-t.live` | 119.29.215.187(SNI) | `t-t.live` | TLSv1.3 | 90 | false |
| `usj.cc` | 119.29.215.187(SNI) | `*.usj.cc` | TLSv1.3 | 90 | false |
| `200181.xyz` | `ssh.200181.xyz` | — | — | 90 | false |
**其它**
- `t-t.live` 5 个站点:PEM 文件 mtime 全前进、端到端 openssl 握手均为 `CN=t-t.live` / `2027-01-04`、`nginx -t` 通过
- `usj.cc`:LiteSSL ECC,SAN `usj.cc + *.usj.cc`;多吉云 `#41963`(`algo=ECDSA 256`)绑 3 个 CDN 域名;
**公网 CDN 与源站直连握手均已验证**(`usj.cc` / `www.usj.cc` 均回 `CN=*.usj.cc` / 2027-01-04)
- certimate 工作流清查与处置见 §9.14
- Worker 侧 `crons = ["17 3 * * *", "0 * * * *"]`(证书 cron 已消失),`certIssue` 改 **501**,`renewAll` 归零;
线上 `admin.js` md5 与本地一致(`60c1452407580413b104e64038ab8b24`)
- 自测 **125/125 通过**(含 §9.12 ④ 的 8 项 PEM 解析回归);`npm run typecheck` 零错误
- 新增 4 个常驻工具:`renew-one.mjs`(单域签发+部署)、`rollback-dogecloud.mjs`(多吉云应急回退)、
`txt-inspect.mjs`(TXT 残留盘点/清理)、`selfcheck-acme.mjs`(CA 账户层诊断)
### 9.14 ★★ certimate 工作流清查(只停「会签发并部署」的)
「本项目的 deployer 已经接管部署」这件事,只有在**没有第二个写者**时才成立。
清查 certimate 全部 7 条工作流后发现:**除 t-t.live 外,usj.cc 与 200181.xyz 的两条申请工作流也一直开着**。
| id | 名称 | 改前 | 处置 | 说明 |
|---|---|---|---|---|
| `bsmqgpygir8l01s` | 优世界证书申请工作流 | **1** | **→ 0** | litessl 签 `*.usj.cc;usj.cc` **RSA2048** → dogecloud-cdn + 1panel |
| `xhydhxamrkyfpkj` | 200181.xyz证书申请工作流 | **1** | **→ 0** | litessl 签 `*.200181.xyz;200181.xyz` **RSA2048**(cloudflare DNS)→ 1panel |
| `bokgwzrapy30ko3` | 团团证书申请工作流 | 0 | 保持 | t-t.live,上一轮已停 |
| `3u6mx7yuelexnjd` | 团团ssl证书过期预警 | 0 | 保持 | 上一轮已停 |
| `ss7skufa40n48ua` | 优世界ssl证书过期预警 | 1 | **保留** | 纯监控 `usj.cc`,≤30 天发邮件,不写任何东西 |
| `xtq2q2eudcuwtr2` | 优世界artalk评论ssl证书过期预警 | 1 | **保留** | 纯监控 `artalk.usj.cc`,≤30 天发邮件 |
| `6f1b67j7y54qm1x` | 伍比贰ssl证书过期预警 | 1 | **保留** | 监控 `5b2.cn`(**非本项目域名**),≤15 天发邮件 |
#### 为什么必须停掉那两条
它们的 `bizApply` 配置里有两把「迟早会开火」的枪:
```
"skipBeforeExpiryDays": 30, // 距到期 >30 天就跳过 → 现在 90 天,暂时不动
"skipOnLastSucceeded": true // 上一次成功就不重复
```
所以**今天到未来约 60 天它静默无害**;但一旦进入「到期前 30 天」窗口,它会:
自己签一张 **RSA2048** 的 `*.usj.cc;usj.cc` → 推到 dogecloud + 1panel → **把我们刚切好的 ECC 证书覆盖回去**,
并与本项目的续期互相打架。这种「60 天后才爆、且爆得静默」的竞争必须提前拆掉。
#### 处置方式
沿用上一轮验证过的姿势(PocketBase 必须先停容器,否则 WAL 回写覆盖):
```bash
docker stop 1Panel-certimate-OKCO
# 合并 WAL
sqlite3 data.db "PRAGMA wal_checkpoint(TRUNCATE);"
# 备份 → 改 enabled → 复核
cp data.db data.db.bak-$(date +%Y%m%d-%H%M%S)
# update workflow set enabled=0 where id in (...)
docker start 1Panel-certimate-OKCO
```
改库脚本带 **名称守卫**(id 与 name 必须同时对上,否则拒绝执行),
避免以后 id 漂移时误停别的工作流。
验收:容器重启 25s 后复核,两条仍为 `enabled=0`,其余五条状态不变。
> 回滚:`sqlite3 data.db "update workflow set enabled=1 where id='bsmqgpygir8l01s';"`
> (库在 `/1panel/1panel/apps/certimate/certimate/data/data.db`,同目录留了 `.bak-20261006-200609`)
#### 遗留:`writeapi.usj.cc` 的手工 vhost 不在本项目的部署面内
全网核验时发现 `writeapi.usj.cc` 的 `ssl/fullchain.pem` 仍是 **旧证书**
(`CN=usj.cc` / RSA / LiteSSL RSA CA / 到期 2026-12-07,mtime 2026-10-04 21:50)。
原因:它是 **1Panel 手工建的 vhost**,nginx 的 `ssl_certificate` 引用**不在 `/www/sites/` 目录树里**
(`grep -rn ssl_certificate sites/writeapi.usj.cc/` 无结果),因此**不走站点管理**,
`/websites/{id}/https` 的绑定操作碰不到它 —— 证书是**文件拷贝**,与证书库记录解耦。
功能上没问题(该证书 SAN 含 `*.usj.cc`,能覆盖 `writeapi.usj.cc`),但:
算法仍是 RSA、62 天后到期、且不会随本项目的续期自动更新 → **需要单独纳管**。