feat(editor-api): 线上容器切「CNB 主 + Gitea 备」双推;备份补 fetch 步骤

用户定案:「editor-api 容器 采用 cnb主仓 gitea备份仓 openlist文件备份」。
三层各有各的失效模式,不能互相替代:
  ① 主仓 origin = CNB          —— 源码 + 触发构建,依赖 CNB 平台
  ② 代码辅仓 gitea = 自建 Gitea —— 不受第三方平台规则约束,可 clone / 按提交追溯
  ③ 离线备份 = F50/OpenList     —— 单一加密文件,平台全挂也能恢复

一、线上容器实测切双推(国内机 119.29.215.187)

先探测出两个关键事实:
  · 国内机**能**直连自建 Gitea(/api/v1/version → 200,0.38s)
  · 国内机**够不到**家里的 OpenList(192.168.0.1 私有地址,6s 超时)
    → 「文件备份」这一层只能由家庭机发起,国内机做不到(下面的 fetch 修复正因此

改动:
  · /srv/blog 停在 432cf5e3,落后 origin/main 10 个提交且是其后代
    → git merge --ff-only 追平到 448b898e,**未强推**
  · 挂 gitea 远端(凭据编在 URL 里,同本机做法)
  · .env:PUSH_REMOTES=origin → origin,gitea;追加 GITEA_URL/USER/PASS
  · docker-compose.yml 默认值同步为 ${PUSH_REMOTES:-origin,gitea}
  · docker compose up -d --build 重建

验证(全部实测通过):
  · 启动日志 [editor-api] 分支 main 推送远端 origin, gitea
  · curl /health → "remotes":["origin","gitea"]
  · 容器内推 origin ✅ / 推 gitea ✅(推临时分支 → ls-remote 确认 → 删分支,无残留)
  · 三处 main 对齐 448b898e(本地 / origin / gitea);工作区干净

二、备份侧:修掉一个静默漏洞 —— 打包前必须先 fetch

git bundle create --all 取的是**本地已知** ref,其中 refs/remotes/origin/main
停在上次 fetch/pull 的位置。而这份备份跑在**家里**那台机器上,工作区并不会
随写作后台(editor-api)的发布自动更新。所以原先是:后台新发的文章**一篇都不
在备份里**,而备份照样报「成功」—— 失败是静默的。

  · backup-bundle.mjs 打包前插入 git fetch --all --tags --prune
    - 失败**不致命**(离线也得出得来备份)→ 降级为「用本地已有 ref 打包」+ 显著告警
    - 新增 --no-fetch 可跳过;步骤号 1/6..6/6 → 0/7..6/7
    - 新增一行 `快照 origin/main = <sha> <时间> <标题>` —— 恢复时第一件要确认的就是它
  · backup-run.mjs:头部补三层说明;失败邮件的「常见原因」加上 fetch 降级这一条

验证:fetch 3s 拉完 origin+gitea → 打包 3.6s → 加密 1.7s → 上传 19.2s(31.5MB/s)
      → 读回 sha256 一致,快照 = 448b898e

三、bootstrap.sh:修掉另一个静默陷阱

脚本每次都会**重写** .env,而 .env 是 GITEA_PASS 的唯一落点 →
「重跑一次但没现给 GITEA_PASS」会把辅仓推送**静默关掉**(PUSH_REMOTES 降级成
origin),不报错、不提示,直到要恢复时才发现 Gitea 早就没在同步。
  · 改为:命令行没给就从旧 .env 里捡回来(显式传新值仍优先)
  · 并把 GITEA_URL/USER/PASS 也写进 .env(容器不读这几个键,仅作复用锚点)
  · 该逻辑用 4 个用例离线验证(有/无 .env、显式覆盖、.env 里没 PASS),
    并确认 set -euo pipefail 下不会被 [ -n ] && cmd 这类写法误触发退出

四、文档

· 架构总览.md:§1.2 地址地图加 editor-api 行;§2 旅程图补「两条推送入口同一套语义」;
  §5.2 加「线上容器的推送目标」段(含验证方法与 pull 源只有 origin 的铁律);
  §5.6 加「打包前必须 git fetch」+「覆盖范围」表(诚实列出够不到的部分);
  §6 待办 #4/#10 更新、新增 #12
· editor-api/README.md:新增「落盘:写一次,存三处」
· README.md:推送说明补线上后台那一路与 fetch 说明;脚本表更新
This commit is contained in:
zqlit committed 2026-10-06 22:27:36 +08:00
1 parent 4308dbe8ea
commit d3da972c3b
6 files changed
+187 -29

No files matched your search

+46 -8
View File
@@ -15,11 +15,21 @@
* 改用 Node 内置 crypto 的 AES-256-GCM:零外部依赖、带认证标签(能检测篡改/截断)、
* 可流式处理 600 MB 不爆内存。加密格式见下方注释,解密由本脚本 `--decrypt` 完成。
*
* ★ 打包前先 `git fetch --all`(2026-10-06 补):
* `git bundle create --all` 取的是**本地已知**的 refs —— 其中
* `refs/remotes/origin/main` 停在上一次 fetch/pull 的位置。
* 而写作后台(editor-api)发的文章只落在 CNB 主仓,本机这份工作区
* 并不会自动跟着更新;不 fetch 就打 bundle,等于把**过期的快照**当成备份,
* 后台最近发的文章一份都不在里面 —— 而且失败是静默的(备份照样"成功")。
* 所以这里先 fetch,再打包。fetch 失败**不致命**(离线也得出得来备份),
* 只降级为「用本地已有 ref 打包」并显著告警。
*
* 用法:
* node scripts/backup-bundle.mjs # 加密 + 上传 + 比对字节数
* node scripts/backup-bundle.mjs # fetch + 加密 + 上传 + 比对字节数
* node scripts/backup-bundle.mjs --verify # 额外下载回来比对 sha256(慢,但端到端最可靠)
* node scripts/backup-bundle.mjs --dry # 只打包加密,不上传
* node scripts/backup-bundle.mjs --keep 7 # 远端保留最近 7 份(默认)
* node scripts/backup-bundle.mjs --no-fetch # 跳过 fetch(离线/调试用)
* node scripts/backup-bundle.mjs --no-encrypt # 不加密(仅当历史里的敏感文件已洗净)
* node scripts/backup-bundle.mjs --decrypt <文件> [--out x.bundle] # 解密(恢复用)
* node scripts/backup-bundle.mjs --list # 列出远端现有备份
@@ -245,16 +255,44 @@ const rawBundle = path.join(tmpDir, `blog-${stamp}.bundle`);
try {
log('仓库:', REPO);
log('1/6 打包 bundle(git bundle create --all)…');
// 0) 先同步远端引用 —— 见文件头「打包前先 git fetch」那段。
// 不 fetch 就会把过期快照当备份,而且失败是静默的(备份照样报"成功")。
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
if (has('no-fetch')) {
log('0/7 跳过 fetch(--no-fetch)—— 本次打包的是**本地当前 ref** 的快照');
} else {
log('0/7 同步远端引用(git fetch --all --tags --prune)…');
try {
const out = execFileSync('git', ['-C', REPO, 'fetch', '--all', '--tags', '--prune'], {
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: gitEnv, timeout: 180000,
});
const tail = (out || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
log(' 完成' + (tail ? ':' + tail : '(无新内容)'));
} catch (e) {
// 离线不该让备份做不出来 —— 降级为用本地已有 ref 打包,但必须显式告警
const why = String(e.stderr || e.stdout || e.message || '').trim().split('\n').filter(Boolean).slice(-2).join(' ');
log(' ★ fetch 失败,降级为「用本地已有 ref 打包」—— 快照可能不含最新提交');
log(' 原因:', why || '(无输出)');
}
}
log('1/7 打包 bundle(git bundle create --all)…');
let t = Date.now();
execFileSync('git', ['-C', REPO, 'bundle', 'create', rawBundle, '--all'], { stdio: ['ignore', 'inherit', 'inherit'] });
log(` 完成 ${mb(fs.statSync(rawBundle).size)} 用时 ${((Date.now() - t) / 1000).toFixed(1)}s`);
// 把「这份快照停在哪」写进日志 —— 恢复时第一件要确认的就是这一行
try {
const head = execFileSync('git', ['-C', REPO, 'log', '-1', '--format=%h %ci %s', 'origin/main'],
{ encoding: 'utf8', env: gitEnv }).trim();
log(' 快照 origin/main =', head);
} catch { /* 没有 origin/main 就不打这行,不影响备份 */ }
let upload = rawBundle;
let finalName = path.basename(rawBundle);
if (ENCRYPT) {
log('2/6 AES-256-GCM 加密(scrypt 派生密钥)…');
log('2/7 AES-256-GCM 加密(scrypt 派生密钥)…');
const enc = rawBundle + '.enc';
t = Date.now();
await encryptFile(rawBundle, enc, cfg.BACKUP_PASSPHRASE);
@@ -263,7 +301,7 @@ try {
finalName = path.basename(enc);
log(` 完成 ${mb(fs.statSync(upload).size)} 用时 ${((Date.now() - t) / 1000).toFixed(1)}s`);
} else {
log('2/6 跳过加密(--no-encrypt)—— 请确认历史里已无敏感文件');
log('2/7 跳过加密(--no-encrypt)—— 请确认历史里已无敏感文件');
}
const size = fs.statSync(upload).size;
@@ -275,10 +313,10 @@ try {
process.exit(0);
}
log('3/6 准备远端目录', DAVDIR);
log('3/7 准备远端目录', DAVDIR);
await ensureDir(DAV_PREFIX, relDirEncoded);
log('4/6 上传中…');
log('4/7 上传中…');
t = Date.now();
const put = await davReq('PUT', davDirPath + '/' + encodeURIComponent(finalName), {
file: upload, extraHeaders: { 'Content-Type': 'application/octet-stream' },
@@ -287,7 +325,7 @@ try {
if (![200, 201, 204].includes(put.status)) throw new Error('上传失败 HTTP ' + put.status);
log(` 完成 ${mb(size)} / ${secs.toFixed(1)}s → ${(size / 1048576 / secs).toFixed(1)} MB/s`);
log('5/6 校验远端');
log('5/7 校验远端');
const stat = await davReq('HEAD', davDirPath + '/' + encodeURIComponent(finalName));
const remoteLen = Number(stat.headers['content-length'] || 0);
if (remoteLen !== size) log(` ★ 远端字节数不一致(本地 ${size} / 远端 ${remoteLen})`);
@@ -313,7 +351,7 @@ try {
fs.unlinkSync(back);
}
log(`6/6 清理旧份(保留最近 ${KEEP} 份)`);
log(`6/7 清理旧份(保留最近 ${KEEP} 份)`);
const ls = await davReq('PROPFIND', davDirPath, { extraHeaders: { Depth: '1' } });
const files = linkNames(ls.body).filter((n) => RE_BACKUP.test(n)).sort().reverse();
log(' 远端现有:', files.join(', ') || '(无)');
+14 -5
View File
@@ -2,21 +2,27 @@
/**
* 备份包装器 —— 给「离线那一层」加一道失败告警
* =====================================================================
* 本项目的三层留存(失效模式各不相同,不能互相替代):
* ① 主仓 `origin` = CNB —— 源码 + 触发构建,依赖 CNB 平台
* ② 代码辅仓 `gitea` = 自建 23.254.236.47:3001 —— 不受第三方平台规则约束,可 clone/追溯
* ③ 离线备份 = 中兴 F50 上的 OpenList —— 单一加密文件,平台全挂也能恢复
* 本脚本负责第 ③ 层。
*
* 为什么需要它:
* OpenList(中兴 F50)上这份加密 bundle 是**唯一不依赖任何 git 服务**的备份 ——
* 在线那一路虽有自建 Gitea 代码辅仓(contributor 机器上,推一次就在),
* 但平台全挂、服务器被回收这类事故只有它能挡。
* 而这类「每天自动跑」的任务最典型的失败模式恰恰是**静默**的 ——
* 第 ③ 层是**唯一不依赖任何 git 服务**的一份 —— 平台全被清、服务器被回收,
* 只有它能恢复。而这类「每天自动跑」的任务最典型的失败模式恰恰是**静默**的 ——
* F50 被带出门、换了网段、OpenList 容器重启后没起来、WebDAV 口令改过……
* 这些都不会有人主动发现,直到某天真的要用备份时才发现最近三个月一份都没成功。
*
* 在线辅仓和主仓都还在,所以这不会立刻致命;但「离线备份悄悄失效」这件事本身
* ①②两层都还在,所以这不会立刻致命;但「离线备份悄悄失效」这件事本身
* 必须被告知。于是加这一层:跑备份 → 失败就发邮件。
*
* 它做什么:
* 1. 读 .workbuddy-backup/openlist-backup.env(KEY=VALUE,# 开头为注释)
* —— 只填充**尚未设置**的环境变量,环境变量优先(与 backup-bundle.mjs 一致)
* 2. 跑 scripts/backup-bundle.mjs,参数原样转发,输出实时透传
* (backup-bundle.mjs 会先 `git fetch --all` 再打包 —— 否则备的是过期快照,
* 后台新发的文章一份都不在里面;fetch 失败只降级告警,不影响出备份)
* 3. 退出码非 0 → 用 scripts/send_mail.js 发告警邮件,正文附日志尾部
* 成功时默认不发(避免每天一封的邮件疲劳);加 --notify-success 才发
* 4. **原样返回 backup-bundle.mjs 的退出码**(发信失败不改变它 ——
@@ -152,6 +158,9 @@ if (code === 0 && !notifySuccess) {
' · WebDAV 账号或口令改过',
' · 仓库体积增长导致超时',
'',
'另:若日志里出现「fetch 失败,降级为用本地已有 ref 打包」,',
'说明备份本身出得来,但快照可能不含最新提交 —— 修复对应远端的凭据/网络后重跑即可。',
'',
'--------- 输出尾部 ---------',
tail.trim() || '(无输出)',
].join('\n');