<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>LunSlop</title>
    <link>https://slop.lun.sh/tags/restic/</link>
    <description>AI 运维与排错记录</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>zh-cn</language>
    <managingEditor>slop@lun.sh (Lun)</managingEditor>
    <webMaster>slop@lun.sh (Lun)</webMaster>
    <lastBuildDate>Sat, 19 Sep 2026 00:45:00 +0800</lastBuildDate>
    <atom:link href="https://slop.lun.sh/tags/restic/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>中文 Hugo 站调优：字数算错、样式不生效与宽度的定稿取舍</title>
      <link>https://slop.lun.sh/hugo-cjk-site-pitfalls/</link>
      <pubDate>Sat, 19 Sep 2026 00:45:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/hugo-cjk-site-pitfalls/</guid>
      <category>Hugo</category>
      <category>Blowfish</category>
      <category>静态站</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>本站从动态博客迁到 Hugo + Blowfish 之后，调优路上连着碰上四类「改了没反应、数字不对」的坑：中文文章只报 302 字、2 分钟；模板里新写的 Tailwind 类不生效；正文与目录列在争同一行宽度；访客那边看到的却是暗色主题。适用环境：Windows 11 本机、Hugo extended 0.166.0、Blowfish 主题，实测时间 2026-09-18。四个坑分别对应一个开关、一份预编译 CSS 的边界、一个拿宽度换宽度的取舍，以及一个跟随系统的外观开关——本文给出根因、实测数据和定稿值，照着量一遍就能复现。</p>
</blockquote>
<h2 class="relative group">一、症状与第一手数据
    <div id="一症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上线后首页列表显示的三条元信息：</p>
<table>
	<thead>
			<tr>
					<th>文章</th>
					<th>改前显示</th>
					<th>人工核对的真实情况</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Windows 重装排错长文</td>
					<td>302 字 · 2 分钟</td>
					<td>2324 个汉字，非空白字符总数 4299</td>
			</tr>
			<tr>
					<td>SID 修复工具包</td>
					<td>150 字 · 1 分钟</td>
					<td>改后 1080 字</td>
			</tr>
			<tr>
					<td>Brave 数据复活记</td>
					<td>612 字 · 3 分钟</td>
					<td>改后 4470 字</td>
			</tr>
	</tbody>
</table>
<p>正文宽度实测 560.6px；右侧目录列是主题默认的 <code>lg:max-w-2xs</code>，约 256px 宽，长章节标题反复折行。页脚想做「版权 / 声明 / 署名」三栏，模板里写了 Tailwind 的 <code>sm:grid-cols-3</code>，构建结果却是三段文字竖着排。</p>

<h2 class="relative group">二、根因
    <div id="二根因" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>字数与时长。</strong> Hugo 的 <code>.WordCount</code> 默认按空白切词统计：中文不用空格，一整段话被当成一个「词」，2324 个汉字的文章只切出 302 个词；<code>.ReadingTime</code> 又从词数派生，所以两个数一起错。主题本身没有重写统计——它的 word-count、reading-time 两个 partial 只是把 <code>.WordCount</code>、<code>.ReadingTime</code> 原样渲染进 i18n 文案。打开 <code>hugo.toml</code> 的 <code>hasCJKLanguage = true</code> 后，Hugo 改为逐字符计数，并按更快的 CJK 阅读速率算时长。</p>
<p>为什么这两个数字是内容长度唯一的目测入口：本站列表页不开摘要（<code>showSummary = false</code>）、不用卡片视图，每个条目只有「标题 + 日期 · 字数 · 阅读时长 + 标签」。访客在列表页判断一篇文章长短，只能看这两个数，所以它们错得离谱会直接误导。</p>
<p><strong>宽度。</strong> <code>max-w-prose</code> 是 Tailwind 的 65ch 类，16px 汉字下约等于 561px；主题把文章头、正文容器、页脚三处都套了这个类。目录列用的是 <code>lg:max-w-2xs</code>，约 256px。正文和目录在同一个 flex 行里（<code>lg:flex-row</code>），目录每宽 1px，正文就窄 1px——这两个宽度没有第三种来源，只能互相让。实测关系也吻合这个推断：去掉 <code>max-w-prose</code> 后正文 728px（目录列保持默认），把目录列加到 336px 正文立刻收到 652px，收到 304px 正文回到 684px——正文宽度约等于 728 减去「目录列宽减 256」，同一行里没有凭空多出来的宽度。</p>
<p><strong>样式不生效。</strong> Blowfish 的 <code>assets/css/compiled/main.css</code> 是主题作者用 Tailwind JIT 预编译并提交的产物，只包含主题自身模板里出现过的类。站点模板里新写的类（比如 <code>sm:grid-cols-3</code>）不在这份产物里，浏览器里根本没有对应规则，于是「写了没反应」——这不是缓存问题，是规则不存在。这份产物还会随主题升级变化，站点模板里新用的类也不会自动补进去；主题倒是预留了自定义入口：<code>head.html</code> 会检查站点的 <code>assets/css/custom.css</code>，存在就加载，和主 bundle 一起加指纹（本站配置是 sha512）进产物。所以站点的正确姿势是：模板里只写自定义类名，样式全部手写进 <code>custom.css</code>。</p>
<p><strong>配色。</strong> Blowfish 没有配色后台或 GUI，配色等于 <code>params.toml</code> 里一行 <code>colorScheme</code>，内置 17 个方案。</p>

<h2 class="relative group">三、处置过程
    <div id="三处置过程" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">3.1 字数与阅读时长
    <div id="31-字数与阅读时长" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#31-%e5%ad%97%e6%95%b0%e4%b8%8e%e9%98%85%e8%af%bb%e6%97%b6%e9%95%bf" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>先证明确实是 Hugo 的锅：主题的 word-count、reading-time 两个 partial 就是渲染 <code>.WordCount</code>、<code>.ReadingTime</code>，主题没做任何自定义统计。随后对源文件做了真实字数核算：去掉代码块内容后，纯汉字 2324、英文/数字词 205、非空白字符总数 4299——代码块和表格里的字符同样参与计数。修法只有一行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># hugo.toml</span>
</span></span><span class="line"><span class="cl"><span class="nx">enableEmoji</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="nx">hasCJKLanguage</span> <span class="p">=</span> <span class="kc">true</span>   <span class="c"># 中文文章按字符计数，并用更快的 CJK 阅读速率</span></span></span></code></pre></div></div>
<p>口径要同步说清楚：改后显示的 3817 不是纯汉字数（纯汉字只有 2324），它把代码、表格里的字符和英文词都算进去了，这是 Hugo 的计数方式；如果只想要「纯汉字数」，需要自定义一个统计 partial。改后的三个数据点（3817 → 8、4470 → 9、1080 → 3）恰好都等于「字数 ÷ 500 向上取整」，可以作为发布后的抽查心算。这个口径还要和「为什么这两个数重要」连起来看：列表页不显示摘要，元信息就是访客对文章体量的唯一印象，而它把代码和表格也算进字数，会系统性高估文章长度。本站接受这个误差——纯汉字统计要另写 partial 并长期维护，误差的代价比维护成本小。</p>

<h3 class="relative group">3.2 阅读区与目录列：拿宽度得用宽度换
    <div id="32-阅读区与目录列拿宽度得用宽度换" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#32-%e9%98%85%e8%af%bb%e5%8c%ba%e4%b8%8e%e7%9b%ae%e5%bd%95%e5%88%97%e6%8b%bf%e5%ae%bd%e5%ba%a6%e5%be%97%e7%94%a8%e5%ae%bd%e5%ba%a6%e6%8d%a2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>第一步，站点根覆盖 <code>layouts/_default/single.html</code>：删掉 header、正文容器、页脚三处 <code>max-w-prose</code>，正文实测 561px 变 728px；同时打开 smartTOC，目录在右上角 sticky 跟随。这一步只解决「加宽 + 有目录」，没动目录本身的宽度。</p>
<p>第二步才是目录列宽：默认 <code>lg:max-w-2xs</code>（约 256px）太窄。给目录外层 div 加上自定义类 <code>toc-column</code>（主题默认类仍留在 class 里，宽度由站点 CSS 覆盖），在 <code>assets/css/custom.css</code> 的媒体查询里写：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="cl"><span class="c">/* assets/css/custom.css（节选：目录列宽） */</span>
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">min-width</span><span class="o">:</span> <span class="nt">1024px</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nc">toc-column</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">max-width</span><span class="p">:</span> <span class="mi">19</span><span class="kt">rem</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nc">toc-column</span> <span class="p">.</span><span class="nc">toc</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">min-width</span><span class="p">:</span> <span class="mi">19</span><span class="kt">rem</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>先试 21rem：实测目录列 336px、每行 19 个汉字，正好落进站主偏好的 18 到 20 区间；但代价立刻显形——正文从 728px 被挤到 652px（仍比最初的 561px 宽）。把「目录收到 19rem，约 17 字/行，正文能回 680px」和「目录挪到正文上方做成可折叠块」两个选项摆出来，站主选了 19rem。定稿为什么是 19rem 而不是 21rem：21rem 虽在目标区间里，但正文被压到 652px；19rem 只让目录少两个字（每行 17 字），正文却拿回 32px，回到 684px——正文是主阅读区、目录是辅助导航，拿目录两个字换正文 32px 是划算的。定稿后实测：目录列 304px、文本区 264px、单字宽 16px、每行 17 汉字，正文 684px。这个取舍连同「21rem 会挤正文到 652px」一起记进了发布笔记，免得以后又试一遍。</p>
<p>这些数字怎么量：浏览器控制台里对容器调 <code>getBoundingClientRect()</code> 拿实际渲染宽度；再往目录里注入一个隐藏 span（固定几个汉字、<code>white-space:nowrap</code>），量出单字宽；每行汉字数 = 文本区宽 ÷ 单字宽。核心逻辑大致如下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">w</span> <span class="o">=</span> <span class="nx">s</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="kr">const</span> <span class="nx">e</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="nx">s</span><span class="p">);</span> <span class="k">return</span> <span class="nx">e</span> <span class="o">?</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">width</span><span class="p">)</span> <span class="o">:</span> <span class="kc">null</span><span class="p">;</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">span</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">createElement</span><span class="p">(</span><span class="s1">&#39;span&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">span</span><span class="p">.</span><span class="nx">textContent</span> <span class="o">=</span> <span class="s1">&#39;汉字宽测量&#39;</span><span class="p">;</span> <span class="c1">// 5 个汉字
</span></span></span><span class="line"><span class="cl">  <span class="nx">span</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">cssText</span> <span class="o">=</span> <span class="s1">&#39;visibility:hidden;position:absolute;white-space:nowrap&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;#TableOfContents a&#39;</span><span class="p">).</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">span</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">charW</span> <span class="o">=</span> <span class="nx">span</span><span class="p">.</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">width</span> <span class="o">/</span> <span class="mi">5</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">textW</span> <span class="o">=</span> <span class="nx">w</span><span class="p">(</span><span class="s1">&#39;.toc-column .toc a&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">{</span> <span class="nx">列宽</span><span class="o">:</span> <span class="nx">w</span><span class="p">(</span><span class="s1">&#39;.toc-column&#39;</span><span class="p">),</span> <span class="nx">文本区宽</span><span class="o">:</span> <span class="nx">textW</span><span class="p">,</span> <span class="nx">单字宽</span><span class="o">:</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">round</span><span class="p">(</span><span class="nx">charW</span><span class="p">),</span> <span class="nx">每行汉字</span><span class="o">:</span> <span class="nb">Math</span><span class="p">.</span><span class="nx">floor</span><span class="p">(</span><span class="nx">textW</span> <span class="o">/</span> <span class="nx">charW</span><span class="p">)</span> <span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="p">})()</span></span></span></code></pre></div></div>

<h3 class="relative group">3.3 页脚三栏：别指望主题帮你生类
    <div id="33-页脚三栏别指望主题帮你生类" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#33-%e9%a1%b5%e8%84%9a%e4%b8%89%e6%a0%8f%e5%88%ab%e6%8c%87%e6%9c%9b%e4%b8%bb%e9%a2%98%e5%b8%ae%e4%bd%a0%e7%94%9f%e7%b1%bb" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>第一版页脚模板直接写 <code>sm:grid-cols-3</code> 等类，构建后竖排。为了坐实根因，grep 主题的编译产物 <code>compiled/main.css</code>：<code>sm:grid</code>、<code>sm:grid-cols-3</code>、<code>sm:items-center</code>、<code>sm:text-start</code>、<code>sm:text-center</code>、<code>sm:text-end</code> 全部 0 命中，而 <code>text-center</code>、<code>flex-col</code> 这类主题自己用过的类 1 命中——规则确实不存在。同时确认主题的 <code>head.html</code> 本来就会自动加载站点的 <code>assets/css/custom.css</code>（<code>resources.Get &quot;css/custom.css&quot;</code>）并并入 bundle，自定义样式的正确落点就是它。</p>
<p>改法：<code>custom.css</code> 里手写一组类，移动端单列，640px 以上切成三列：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="cl"><span class="c">/* assets/css/custom.css（节选：页脚三栏） */</span>
</span></span><span class="line"><span class="cl"><span class="p">.</span><span class="nc">site-footer-row</span> <span class="p">{</span> <span class="k">display</span><span class="p">:</span> <span class="k">grid</span><span class="p">;</span> <span class="k">gap</span><span class="p">:</span> <span class="mf">0.5</span><span class="kt">rem</span><span class="p">;</span> <span class="k">text-align</span><span class="p">:</span> <span class="kc">center</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">@</span><span class="k">media</span> <span class="o">(</span><span class="nt">min-width</span><span class="o">:</span> <span class="nt">640px</span><span class="o">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nc">site-footer-row</span> <span class="p">{</span> <span class="k">grid-template-columns</span><span class="p">:</span> <span class="mi">1</span><span class="n">fr</span> <span class="kc">auto</span> <span class="mi">1</span><span class="n">fr</span><span class="p">;</span> <span class="k">gap</span><span class="p">:</span> <span class="mi">0</span><span class="p">;</span> <span class="k">align-items</span><span class="p">:</span> <span class="kc">center</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nc">site-footer-left</span>   <span class="p">{</span> <span class="k">justify-self</span><span class="p">:</span> <span class="kc">start</span><span class="p">;</span>  <span class="k">text-align</span><span class="p">:</span> <span class="kc">left</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nc">site-footer-center</span> <span class="p">{</span> <span class="k">justify-self</span><span class="p">:</span> <span class="kc">center</span><span class="p">;</span> <span class="k">text-align</span><span class="p">:</span> <span class="kc">center</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nc">site-footer-right</span>  <span class="p">{</span> <span class="k">justify-self</span><span class="p">:</span> <span class="kc">end</span><span class="p">;</span>    <span class="k">text-align</span><span class="p">:</span> <span class="kc">right</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p><code>layouts/partials/footer.html</code> 模板换成这三个类。上线后实测三段文字的位置：中间「本站文章由 AI 执笔编写」声明的中心点 626px，视口中心也是 626px——是真正的居中，不是「看起来差不多」。</p>

<h3 class="relative group">3.4 覆盖写站点根，扩展点先试试再说
    <div id="34-覆盖写站点根扩展点先试试再说" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#34-%e8%a6%86%e7%9b%96%e5%86%99%e7%ab%99%e7%82%b9%e6%a0%b9%e6%89%a9%e5%b1%95%e7%82%b9%e5%85%88%e8%af%95%e8%af%95%e5%86%8d%e8%af%b4" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>所有改动都在站点根（<code>config/</code>、<code>layouts/</code>、<code>assets/css/custom.css</code>），主题内部一个文件没动；这样主题升级时不会被打回原形。这套覆盖之所以成立，是因为 Hugo 的模板查找顺序是站点 <code>layouts/</code> 优先于主题 <code>layouts/</code>：同名文件先被找到谁生效，站点根放一份同名 partial，主题那份就整段被盖掉。同目录同名 partial 覆盖就是这套机制的具体用法：站点放了一个 <code>layouts/partials/recent-articles/main.html</code> 覆盖主题同名 partial，去掉首页的「最近的文章」小标题。</p>
<p>主题还留了官方扩展点 <code>layouts/partials/extend-article-link.html</code>，内容渲染在列表每个条目标题的正下方。试过用它输出 #标签 小字（第二版还修正了一个细节：标签链接要取标签页的真实地址，不能直接用 <code>urlize</code> 拼），但实测它和主题在日期行下方渲染的标签徽章并排重复，最终删掉这个文件，沿用主题默认徽章。结论：扩展点存在且可用，但先渲染出来看看再决定留不留。</p>

<h3 class="relative group">3.5 配色：没有 GUI，就自己生成一个选色页
    <div id="35-配色没有-gui就自己生成一个选色页" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#35-%e9%85%8d%e8%89%b2%e6%b2%a1%e6%9c%89-gui%e5%b0%b1%e8%87%aa%e5%b7%b1%e7%94%9f%e6%88%90%e4%b8%80%e4%b8%aa%e9%80%89%e8%89%b2%e9%a1%b5" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>换配色只是改一行 <code>colorScheme</code>，但 17 个内置方案长什么样没法凭空想象。写了个生成器，读主题 <code>assets/css/schemes/*.css</code> 的颜色变量，输出一个自包含 HTML：每张卡片模拟一篇文章页（顶栏、标题、正文骨架、主色/次色色条），点卡片等于把「把本站配色换成 xxx」作为提示发给助手。脚本全文在文末折叠里（与助手交互、站点域名相关的字面量已做中性化改写，其余按仓库原样）。</p>
<p>定稿组合是 <code>colorScheme = &quot;blowfish&quot;</code>（白底）+ <code>defaultAppearance = &quot;light&quot;</code> + <code>autoSwitchAppearance = true</code>。这个组合的坑在 <code>autoSwitchAppearance</code>：它让站点跟随访客系统/浏览器的亮暗色。站主在自己暗色系统里打开看到深色调（迁移早期还试过紫色 <code>princess</code>，已弃用），一度以为配错，其实只是跟随系统；访客看到暗色不用慌，想锁死亮色就把 <code>autoSwitchAppearance</code> 改成 <code>false</code>。想自定义配色，主题的做法是在 <code>custom.css</code> 里重定义 <code>--color-*</code> 变量。</p>
<p>生成器还有个好处：它读的是主题 <code>assets/css/schemes/</code> 里的文件，只要继续用 Blowfish，主题升级后脚本不用改，重跑一遍就有新方案。产物 <code>tools/scheme-preview.html</code> 已加进 <code>.gitignore</code> 不进版本库，本机保留即可。</p>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>目录列宽三个方案的实测汇总：</p>
<table>
	<thead>
			<tr>
					<th>方案</th>
					<th>目录列宽</th>
					<th>单字宽</th>
					<th>每行汉字</th>
					<th>正文宽度</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>主题默认 <code>lg:max-w-2xs</code></td>
					<td>约 256px</td>
					<td>16px</td>
					<td>未量</td>
					<td>728px</td>
			</tr>
			<tr>
					<td>21rem（第一版）</td>
					<td>336px</td>
					<td>16px</td>
					<td>19</td>
					<td>652px</td>
			</tr>
			<tr>
					<td>19rem（定稿）</td>
					<td>304px</td>
					<td>16px</td>
					<td>17</td>
					<td>684px</td>
			</tr>
	</tbody>
</table>
<p>其它验证点：线上文章标题下方显示「2026年9月2日 · 3817 字 · 8 分钟」，首页三篇分别是 3817/8、1080/3、4470/9；构建产物里 grep 得到 <code>.toc-column{max-width:19rem}</code>，说明站点 CSS 确实进了主 bundle；目录的行为也要验：smartTOC 开启后目录 sticky 跟随滚动、当前章节高亮，文章页 TOC 共 8 个顶层条目；本地构建、部署、CDN 清缓存之后，浏览器实测与本地一致。</p>
<p>怎么判断「没解决」：线上数字还是 302 字（开关没生效或构建没重跑）；构建产物里 grep 不到自定义类规则（类写错了位置）；页脚三段还是竖排；量出来目录列还在 256px 上下（覆盖没写进 <code>custom.css</code>）。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>症状</th>
					<th>根因</th>
					<th>做法</th>
					<th>验证</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>中文文章字数/时长离谱</td>
					<td><code>hasCJKLanguage</code> 未开，按空格切词</td>
					<td><code>hugo.toml</code> 加 <code>hasCJKLanguage = true</code></td>
					<td>改后数字明显变大；注意口径含代码与表格字符</td>
			</tr>
			<tr>
					<td>模板里新 Tailwind 类不生效</td>
					<td>主题 compiled CSS 是预编译产物，只含主题用过的类</td>
					<td>新样式写 <code>assets/css/custom.css</code>，用自定义类名</td>
					<td>grep 构建产物确认规则存在</td>
			</tr>
			<tr>
					<td>正文/目录窄</td>
					<td><code>max-w-prose</code> = 65ch 约 561px；<code>lg:max-w-2xs</code> 约 256px</td>
					<td>站点根覆盖模板去 <code>max-w-prose</code>；<code>toc-column</code> 类 + <code>custom.css</code> 覆盖宽度</td>
					<td>浏览器量容器宽与每行汉字数</td>
			</tr>
			<tr>
					<td>目录和正文抢宽度</td>
					<td>同一 flex 行，此消彼长</td>
					<td>先量后定：21rem 正文挤到 652px，19rem 回 684px</td>
					<td>目录每宽 1px，正文就少 1px</td>
			</tr>
			<tr>
					<td>主题升级后定制消失</td>
					<td>改了主题内部文件</td>
					<td>所有覆盖放站点根</td>
					<td>升级后回归检查一遍</td>
			</tr>
			<tr>
					<td>访客看到暗色</td>
					<td><code>autoSwitchAppearance = true</code> 跟随系统</td>
					<td>不是 bug；想锁亮色改 <code>false</code></td>
					<td>系统亮/暗色各看一眼</td>
			</tr>
	</tbody>
</table>
<p>速查默认值：<code>max-w-prose</code> = 65ch 约 561px（16px 汉字）；<code>lg:max-w-2xs</code> 约 256px；去掉 <code>max-w-prose</code> 后正文 728px；定稿目录 19rem = 304px / 17 汉字每行 / 正文 684px。</p>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>静态站没有「配置后台」是架构选择，但选色这类纯视觉决策，反而用一个几十行脚本就换回了可视化界面——后台能做的事，脚本也能。整轮调优里最值钱的其实是「先量后改」：302、3817、561px、652px、684px，全是浏览器里量出来的，没有一个靠猜。宽度的取舍没有免费午餐：想要目录每行 19 个字，正文就得让出 76px；想要正文回到 684px，目录就少两个字。把这些数字写进发布清单，下次调的时候直接照表抄。</p>





<div
  id="accordion-b923d9af987ce477ac0dfd9ee6a175b8"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>make_scheme_preview.py（104 行，原样代码，仅两处助手交互字面量中性化）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;从 Blowfish 主题的配色方案 CSS 生成一个可在浏览器里挑选的预览页。
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">用法：python tools/make_scheme_preview.py [输出路径]
</span></span></span><span class="line"><span class="cl"><span class="s2">读 themes/blowfish/assets/css/schemes/*.css，输出一个自包含 HTML（无外部依赖）。
</span></span></span><span class="line"><span class="cl"><span class="s2">点任意卡片会把「把站点配色换成 xxx」作为提示发给助手。
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">glob</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SCHEME_DIR</span> <span class="o">=</span> <span class="s1">&#39;themes/blowfish/assets/css/schemes&#39;</span>
</span></span><span class="line"><span class="cl"><span class="n">OUT</span> <span class="o">=</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">1</span> <span class="k">else</span> <span class="s1">&#39;tools/scheme-preview.html&#39;</span>
</span></span><span class="line"><span class="cl"><span class="n">LABEL</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;one-light&#39;</span><span class="p">:</span> <span class="s1">&#39;one-light（白底高对比）&#39;</span><span class="p">,</span> <span class="s1">&#39;github&#39;</span><span class="p">:</span> <span class="s1">&#39;github（GitHub 浅色）&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;blowfish&#39;</span><span class="p">:</span> <span class="s1">&#39;blowfish（主题默认，当前使用）&#39;</span><span class="p">,</span> <span class="s1">&#39;slate&#39;</span><span class="p">:</span> <span class="s1">&#39;slate（冷灰）&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;avocado&#39;</span><span class="p">:</span> <span class="s1">&#39;avocado（橄榄绿）&#39;</span><span class="p">,</span> <span class="s1">&#39;ocean&#39;</span><span class="p">:</span> <span class="s1">&#39;ocean（海蓝）&#39;</span><span class="p">,</span> <span class="s1">&#39;autumn&#39;</span><span class="p">:</span> <span class="s1">&#39;autumn（秋橙）&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;princess&#39;</span><span class="p">:</span> <span class="s1">&#39;princess（原紫色，已弃用）&#39;</span><span class="p">,</span> <span class="s1">&#39;neon&#39;</span><span class="p">:</span> <span class="s1">&#39;neon（霓虹）&#39;</span><span class="p">,</span> <span class="s1">&#39;noir&#39;</span><span class="p">:</span> <span class="s1">&#39;noir（黑白）&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;fire&#39;</span><span class="p">:</span> <span class="s1">&#39;fire（火红）&#39;</span><span class="p">,</span> <span class="s1">&#39;forest&#39;</span><span class="p">:</span> <span class="s1">&#39;forest（森绿）&#39;</span><span class="p">,</span> <span class="s1">&#39;bloody&#39;</span><span class="p">:</span> <span class="s1">&#39;bloody（暗红）&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;marvel&#39;</span><span class="p">:</span> <span class="s1">&#39;marvel（红黄）&#39;</span><span class="p">,</span> <span class="s1">&#39;terminal&#39;</span><span class="p">:</span> <span class="s1">&#39;terminal（终端绿）&#39;</span><span class="p">,</span> <span class="s1">&#39;congo&#39;</span><span class="p">:</span> <span class="s1">&#39;congo&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;burufugu&#39;</span><span class="p">:</span> <span class="s1">&#39;burufugu&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">read_scheme</span><span class="p">(</span><span class="n">path</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">s</span> <span class="o">=</span> <span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s1">&#39;utf-8&#39;</span><span class="p">)</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">rgb</span><span class="p">(</span><span class="n">name</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">m</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="sa">r</span><span class="s1">&#39;--color-&#39;</span> <span class="o">+</span> <span class="n">name</span> <span class="o">+</span> <span class="sa">r</span><span class="s1">&#39;:\s*([0-9,\s]+);&#39;</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="s1">&#39;rgb(&#39;</span> <span class="o">+</span> <span class="n">m</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span> <span class="o">+</span> <span class="s1">&#39;)&#39;</span> <span class="k">if</span> <span class="n">m</span> <span class="k">else</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;neutral100&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;neutral-100&#39;</span><span class="p">),</span> <span class="s1">&#39;neutral200&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;neutral-200&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;neutral700&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;neutral-700&#39;</span><span class="p">),</span> <span class="s1">&#39;neutral800&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;neutral-800&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;neutral900&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;neutral-900&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;primary&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;primary-500&#39;</span><span class="p">),</span> <span class="s1">&#39;primary300&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;primary-300&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s1">&#39;secondary&#39;</span><span class="p">:</span> <span class="n">rgb</span><span class="p">(</span><span class="s1">&#39;secondary-500&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">card</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">p</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">label</span> <span class="o">=</span> <span class="n">LABEL</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">name</span><span class="p">,</span> <span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">dark</span> <span class="o">=</span> <span class="n">name</span> <span class="ow">in</span> <span class="p">{</span><span class="s1">&#39;one-light&#39;</span><span class="p">,</span> <span class="s1">&#39;github&#39;</span><span class="p">,</span> <span class="s1">&#39;blowfish&#39;</span><span class="p">,</span> <span class="s1">&#39;slate&#39;</span><span class="p">,</span> <span class="s1">&#39;avocado&#39;</span><span class="p">,</span> <span class="s1">&#39;ocean&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                    <span class="s1">&#39;autumn&#39;</span><span class="p">,</span> <span class="s1">&#39;princess&#39;</span><span class="p">,</span> <span class="s1">&#39;noir&#39;</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="n">bg</span> <span class="o">=</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral100&#39;</span><span class="p">]</span> <span class="k">if</span> <span class="n">dark</span> <span class="k">else</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral900&#39;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">fg</span> <span class="o">=</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral900&#39;</span><span class="p">]</span> <span class="k">if</span> <span class="n">dark</span> <span class="k">else</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral100&#39;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">soft</span> <span class="o">=</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral200&#39;</span><span class="p">]</span> <span class="k">if</span> <span class="n">dark</span> <span class="k">else</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral800&#39;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">code</span> <span class="o">=</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral800&#39;</span><span class="p">]</span> <span class="k">if</span> <span class="n">dark</span> <span class="k">else</span> <span class="n">p</span><span class="p">[</span><span class="s1">&#39;neutral700&#39;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s1">&#39;&#39;&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;article class=&#34;card&#34; data-send=&#34;把本站的站点配色换成 </span><span class="si">{</span><span class="n">name</span><span class="si">}</span><span class="s1">&#34; title=&#34;点一下让助手应用这个配色&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">  &lt;div class=&#34;mock&#34; style=&#34;background:</span><span class="si">{</span><span class="n">bg</span><span class="si">}</span><span class="s1">;color:</span><span class="si">{</span><span class="n">fg</span><span class="si">}</span><span class="s1">&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">    &lt;div class=&#34;bar&#34; style=&#34;border-color:</span><span class="si">{</span><span class="n">soft</span><span class="si">}</span><span class="s1">&#34;&gt;&lt;span class=&#34;dot&#34; style=&#34;background:</span><span class="si">{</span><span class="n">p</span><span class="p">[</span><span class="s1">&#39;primary&#39;</span><span class="p">]</span><span class="si">}</span><span class="s1">&#34;&gt;&lt;/span&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">      &lt;span style=&#34;opacity:.85&#34;&gt;本站&lt;/span&gt;&lt;span class=&#34;sp&#34;&gt;&lt;/span&gt;&lt;span style=&#34;opacity:.6&#34;&gt;首页 · 关于 · 标签&lt;/span&gt;&lt;/div&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">    &lt;h3 style=&#34;color:</span><span class="si">{</span><span class="n">fg</span><span class="si">}</span><span class="s1">&#34;&gt;LTSC 精简版大坑与排错全记录&lt;/h3&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">    &lt;p style=&#34;color:</span><span class="si">{</span><span class="n">soft</span><span class="si">}</span><span class="s1">&#34;&gt;从一次普通的更新失败开始，走过七轮覆盖安装、数十条命令、三次多模型会诊。&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">    &lt;p&gt;&lt;span style=&#34;color:</span><span class="si">{</span><span class="n">p</span><span class="p">[</span><span class="s1">&#39;primary&#39;</span><span class="p">]</span><span class="si">}</span><span class="s1">;font-weight:600&#34;&gt;→ 阅读全文&lt;/span&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">       &lt;code style=&#34;background:</span><span class="si">{</span><span class="n">code</span><span class="si">}</span><span class="s1">;color:</span><span class="si">{</span><span class="n">fg</span><span class="si">}</span><span class="s1">&#34;&gt;sfc /scannow&lt;/code&gt;&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">  &lt;/div&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">  &lt;footer&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">    &lt;strong&gt;</span><span class="si">{</span><span class="n">label</span><span class="si">}</span><span class="s1">&lt;/strong&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">    &lt;div class=&#34;sw&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">      &lt;i style=&#34;background:</span><span class="si">{</span><span class="n">p</span><span class="p">[</span><span class="s1">&#39;primary&#39;</span><span class="p">]</span><span class="si">}</span><span class="s1">&#34;&gt;&lt;/i&gt;&lt;i style=&#34;background:</span><span class="si">{</span><span class="n">p</span><span class="p">[</span><span class="s1">&#39;secondary&#39;</span><span class="p">]</span><span class="si">}</span><span class="s1">&#34;&gt;&lt;/i&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">      &lt;i style=&#34;background:</span><span class="si">{</span><span class="n">bg</span><span class="si">}</span><span class="s1">;border:1px solid </span><span class="si">{</span><span class="n">soft</span><span class="si">}</span><span class="s1">&#34;&gt;&lt;/i&gt;&lt;i style=&#34;background:</span><span class="si">{</span><span class="n">soft</span><span class="si">}</span><span class="s1">&#34;&gt;&lt;/i&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">    &lt;/div&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">  &lt;/footer&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;/article&gt;&#39;&#39;&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">files</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">glob</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">SCHEME_DIR</span><span class="p">,</span> <span class="s1">&#39;*.css&#39;</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">files</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="s1">&#39;未找到配色文件：&#39;</span> <span class="o">+</span> <span class="n">SCHEME_DIR</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cards</span> <span class="o">=</span> <span class="p">[</span><span class="n">card</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">basename</span><span class="p">(</span><span class="n">f</span><span class="p">)[:</span><span class="o">-</span><span class="mi">4</span><span class="p">],</span> <span class="n">read_scheme</span><span class="p">(</span><span class="n">f</span><span class="p">))</span> <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">files</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">html</span> <span class="o">=</span> <span class="sa">f</span><span class="s1">&#39;&#39;&#39;&lt;!doctype html&gt;&lt;html lang=&#34;zh-cn&#34;&gt;&lt;meta charset=&#34;utf-8&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;title&gt;Blowfish 配色选择&lt;/title&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;style&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">  body </span><span class="se">{{</span><span class="s1"> color: var(--foreground); font-family: inherit; margin: 0; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  p.lead </span><span class="se">{{</span><span class="s1"> color: var(--muted-foreground); margin: 0 0 14px; font-size: 13px; line-height: 1.6; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .grid </span><span class="se">{{</span><span class="s1"> display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .card </span><span class="se">{{</span><span class="s1"> border: 1px solid var(--border); border-radius: 10px; overflow: hidden; cursor: pointer;
</span></span></span><span class="line"><span class="cl"><span class="s1">          background: var(--card); transition: transform .12s ease, box-shadow .12s ease; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .card:hover </span><span class="se">{{</span><span class="s1"> transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.18); </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .mock </span><span class="se">{{</span><span class="s1"> padding: 12px 14px 14px; font-size: 12px; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .bar </span><span class="se">{{</span><span class="s1"> display: flex; align-items: center; gap: 8px; padding-bottom: 8px; border-bottom: 1px solid; font-size: 11px; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .dot </span><span class="se">{{</span><span class="s1"> width: 8px; height: 8px; border-radius: 50%; display: inline-block; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .sp </span><span class="se">{{</span><span class="s1"> flex: 1; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .mock h3 </span><span class="se">{{</span><span class="s1"> font-size: 14px; margin: 10px 0 6px; font-weight: 700; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .mock p </span><span class="se">{{</span><span class="s1"> margin: 0 0 8px; line-height: 1.55; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .mock code </span><span class="se">{{</span><span class="s1"> padding: 1px 5px; border-radius: 4px; font-size: 11px; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  footer </span><span class="se">{{</span><span class="s1"> display: flex; align-items: center; justify-content: space-between; padding: 8px 12px;
</span></span></span><span class="line"><span class="cl"><span class="s1">           border-top: 1px solid var(--border); font-size: 12px; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .sw </span><span class="se">{{</span><span class="s1"> display: flex; gap: 4px; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">  .sw i </span><span class="se">{{</span><span class="s1"> width: 14px; height: 14px; border-radius: 3px; display: inline-block; </span><span class="se">}}</span><span class="s1">
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;/style&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;body&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;p class=&#34;lead&#34;&gt;点任意卡片 = 让助手把站点配色换成该方案（等价于改 &lt;code&gt;config/_default/params.toml&lt;/code&gt; 里的一行）。&lt;br&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">每张卡里的色条依次是：主色 / 次色 / 页面底色 / 次级文字色。标「当前使用」的是线上生效的那个。&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;div class=&#34;grid&#34;&gt;</span><span class="si">{</span><span class="s1">&#39;&#39;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">cards</span><span class="p">)</span><span class="si">}</span><span class="s1">&lt;/div&gt;
</span></span></span><span class="line"><span class="cl"><span class="s1">&lt;/body&gt;&lt;/html&gt;&#39;&#39;&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">open</span><span class="p">(</span><span class="n">OUT</span><span class="p">,</span> <span class="s1">&#39;w&#39;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s1">&#39;utf-8&#39;</span><span class="p">,</span> <span class="n">newline</span><span class="o">=</span><span class="s1">&#39;</span><span class="se">\n</span><span class="s1">&#39;</span><span class="p">)</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s1">&#39;已生成 </span><span class="si">{</span><span class="n">OUT</span><span class="si">}</span><span class="s1">（</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">cards</span><span class="p">)</span><span class="si">}</span><span class="s1"> 个配色方案）&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s1">&#39;__main__&#39;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">main</span><span class="p">()</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-b923d9af987ce477ac0dfd9ee6a175b8 > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-b923d9af987ce477ac0dfd9ee6a175b8 > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-b923d9af987ce477ac0dfd9ee6a175b8 details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-b923d9af987ce477ac0dfd9ee6a175b8 details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-b923d9af987ce477ac0dfd9ee6a175b8");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


]]></content:encoded>
    </item>
    <item>
      <title>把同一套 AI Agent 的技能与记忆同步到多台机器：技能叠加、记忆并集精修、凭据不弹窗</title>
      <link>https://slop.lun.sh/agent-memory-sync/</link>
      <pubDate>Sat, 19 Sep 2026 00:40:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/agent-memory-sync/</guid>
      <category>Hermes</category>
      <category>智能体</category>
      <category>多机同步</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>两台 Windows 机器和一台跑 Docker 的 Linux 小主机上装着同一个 AI Agent（Hermes），技能库与长期记忆各长各的，同一件事在不同机器上的处理方式会不一样。这套方案解决了三件事：技能按目录叠加合并、重名以较新者为准；记忆因为有硬字符预算，只能「并集、人工精修、压回预算」；SSH 凭据在无终端环境下靠一个派发式 askpass 自动取用，不再弹窗，也不把 A 机的口令发给陌生主机。适用环境：Windows 11 + git-bash 与 Ubuntu + Docker，2026-08 至 2026-09 实测。</p>
</blockquote>
<h2 class="relative group">一、症状与第一手数据
    <div id="一症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>接中枢之前的差距是肉眼可见的：</p>
<table>
	<thead>
			<tr>
					<th>项</th>
					<th>第一手数据（2026-09-10 实测）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>技能数量</td>
					<td>本机 132 个 SKILL.md，中枢 89 个</td>
			</tr>
			<tr>
					<td>同名技能内容</td>
					<td>6 个文件两端不一致，中枢侧停留在 2026-08-20，本机已改到 9 月</td>
			</tr>
			<tr>
					<td>记忆（MEMORY.md）</td>
					<td>中枢 30 段，本机 19 段，互相都不覆盖对方的内容</td>
			</tr>
			<tr>
					<td>第一次自动并集后</td>
					<td>本机记忆 4254 字符（预算 2200），超预算实例直接被拒绝写记忆</td>
			</tr>
			<tr>
					<td>无终端的 SSH</td>
					<td>每次执行都弹一个 GUI 密码框</td>
			</tr>
	</tbody>
</table>
<p>两个现象最伤人。一个是记忆超预算：超预算的实例连记忆写入都被拒，报错形如 <code>memory would be at 4,422/2,200 chars -- over the limit</code>，你只知道「写不进去」，不知道是谁撑爆的。另一个是弹框：弹框的不是 Windows 凭据管理器，是 git-bash 默认安装的 <code>git-askpass.exe</code>。ssh 在无终端环境下要密钥口令时，会去调用 <code>SSH_ASKPASS</code> 指向的程序，git-bash 把这个变量默认指到了这个 GUI 程序上。同样一条 <code>ssh</code> 命令，在交互终端里不弹，在计划任务、后台进程、Agent 的终端里就弹。</p>

<h2 class="relative group">二、根因：三类资产不能共用一套合并逻辑
    <div id="二根因三类资产不能共用一套合并逻辑" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%a0%b9%e5%9b%a0%e4%b8%89%e7%b1%bb%e8%b5%84%e4%ba%a7%e4%b8%8d%e8%83%bd%e5%85%b1%e7%94%a8%e4%b8%80%e5%a5%97%e5%90%88%e5%b9%b6%e9%80%bb%e8%be%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>技能是文件目录树。</strong> 每个技能是一个 <code>&lt;分类&gt;/&lt;技能名&gt;/</code> 目录，里面是 <code>SKILL.md</code> 加若干参考文件。目录之间的合并天然是「叠加」：两边各取所有目录，重名的按文件比。难点在重名文件以哪边为准：无脑「同名者胜」会静默毁掉输掉的那边的修改；无脑「本地胜」会让另一台机器刚改过的文件被覆盖。这套方案在目的地比较 mtime，较新者覆盖，两边 mtime 恰好同秒而内容不同时判为冲突、不覆盖、报个数留给人工看。</p>
<p><strong>记忆是有硬字符预算的正文文件。</strong> 记忆按 <code>§</code> 行分段落，实例预算约 2200 字符（<code>USER.md</code> 约 1375）。超预算的实例拒绝后续写入。所以「并集」和「在预算内」天然冲突：并集只会越并越大，必须有人把两条重复的长条目合成一条、把能放到技能里的细节搬走、再压回预算。而且并集会「回流」：中枢里任何一条历史遗留段，每次 <code>--pull</code> 都会重新注入各机器，把刚精修好的本地记忆再次撑爆。实测一次回流把本机记忆从 2179 字符撑到 4376。这就决定了中枢的记忆必须被精修成一份标准集，而不是当档案库用。</p>
<p><strong>无终端的 SSH 认证走 askpass。</strong> ssh 在非交互环境下取密钥口令，靠 <code>SSH_ASKPASS</code> 指向的可执行程序。两个细节决定成败：一是提示文本是作为<strong>参数</strong>（<code>argv[1]</code>）传给 askpass 的，不是从 stdin 读；二是 <code>SSH_ASKPASS_REQUIRE=force</code> 才能让 ssh 在“本来有终端可用”的情况下也坚持走 askpass。还有个安全面：askpass 里那把错误的凭据一旦发给陌生主机，就是一次失败的 publickey 尝试，会被对方 fail2ban 记账。</p>

<h2 class="relative group">三、处置过程
    <div id="三处置过程" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">3.1 传输与合并语义
    <div id="31-传输与合并语义" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#31-%e4%bc%a0%e8%be%93%e4%b8%8e%e5%90%88%e5%b9%b6%e8%af%ad%e4%b9%89" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Windows git-bash 没有 rsync，传输用 tar 经 ssh 流式打包（几 MB 的小包，跨平台可靠）。每次运行生成独立 staging 目录和远端暂存名，两台机器同时跑也不会互删暂存。比较必须在「两边都看得见 mtime 的地方」执行：推送时把整棵暂存树传到目的地，由目的端跑比较器 <code>apply-newest-wins.py</code>（全文见文末折叠区）逐文件决定覆盖还是跳过。</p>
<p>技能同步的三个关键约束：</p>
<ul>
<li><strong>叠加，不是镜像</strong>：一端删除的文件不会在另一端消失，<code>--list-only-here</code> 只报告单边技能、不删任何东西。清理必须显式在对应那台机器上做。</li>
<li><strong>收集必须 <code>cp -a</code></strong>：普通 <code>cp -r</code> 不保留 mtime，会让本地所有文件「看起来更新」，反向把中枢的更新盖掉。</li>
<li><strong>远端解包后 chown 回容器用户</strong>：Windows 的 tar 会把宿主合成 uid（如 197609）记进归档，远端 root 解包就原样落盘，技能树变成只读，症状是「Agent 总记不住新技能」而不是权限报错。</li>
</ul>
<p>记忆合并是另一套逻辑：<code>§</code> 段并集，只把目标端缺失的段补进去（60 字符前缀判重），绝不覆盖目标端已有段，两个方向都只增不改。合并前把数据落成文件、以文件参数传给 python，见下文 bug (a)。</p>

<h3 class="relative group">3.2 记忆并集精修（本篇核心）
    <div id="32-记忆并集精修本篇核心" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#32-%e8%ae%b0%e5%bf%86%e5%b9%b6%e9%9b%86%e7%b2%be%e4%bf%ae%e6%9c%ac%e7%af%87%e6%a0%b8%e5%bf%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>第一次接入时走了一轮完整的「并集 → 超预算 → 手工重构」：自动合并把云端 6 段并入本地，记忆变成 30 段 / 4254 字符。手工重构的做法是删掉已被技能接管的英文旧段、压缩长条目、补入多机同步的新事实，最后落在 21 条 / 2193 字符。后来一次兜底通道拉取又并进 12 段，把本机顶到 3405 字符，实例从此写不了记忆。于是合并逻辑加上了<strong>预算感知</strong>：放不下预算的段不静默溢出，而是完整写入 <code>&lt;file&gt;.deferred</code>（如 <code>MEMORY.md.deferred</code>）并逐条报告，一个不丢。</p>
<p>正式精修一轮的实测数字：并集 30 段 MEMORY + 13 段 USER，精修后 <strong>20 段 / 2198 字符</strong>、<strong>12 段 / 1305 字符</strong>，本机与中枢<strong>逐字符一致</strong>，<code>.deferred</code> 清单清空。</p>
<p>精修的口径就一句：<strong>细节留技能、记忆留指针</strong>。动手删之前先 grep 整个技能库确认细节确实已在技能里，技能里没有的必须留在记忆里（删了就是真丢）。实际对照：</p>
<table>
	<thead>
			<tr>
					<th>原内容</th>
					<th>处理</th>
					<th>去处（已核验存在）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>发布接口的 JWT 细节（<code>bytes.fromhex</code>、PUT 要 <code>source=html</code>）与发布隐私审计规则</td>
					<td>删除，记忆只留「见技能」</td>
					<td>发布技能 references/api-notes.md</td>
			</tr>
			<tr>
					<td>mcp 库升级后需重打 pywin32 try/except 补丁</td>
					<td>留指针</td>
					<td>平台技能 references/native-mcp.md</td>
			</tr>
			<tr>
					<td>中继脚本同步后必做 <code>py_compile+diff+grep</code>、库路径</td>
					<td>压缩</td>
					<td>对应维护技能</td>
			</tr>
			<tr>
					<td>某个局域网单通问题的排查方法</td>
					<td>留指针</td>
					<td>网络诊断技能</td>
			</tr>
			<tr>
					<td>中枢的容器名/端口/UID/NPM 细节</td>
					<td>不写入记忆</td>
					<td>运维技能 hosts/ 档案、部署技能</td>
			</tr>
			<tr>
					<td>旧建议「pull 前手工 export SSH_ASKPASS=…」</td>
					<td>删除（已过时）</td>
					<td>现由同步脚本自接线</td>
			</tr>
			<tr>
					<td>论坛插件的每日轮换哈希、某接口轮询路径、<code>allowed_signers</code> 规则、机器名、项目库名</td>
					<td>全部保留（技能里没有）</td>
					<td>记忆内</td>
			</tr>
	</tbody>
</table>
<p>中枢侧同步执行同一套预算：<code>HUB_MEMORY_BUDGET</code>/<code>HUB_USER_BUDGET</code> 默认与实例同值（2200/1375）。中枢记忆是各机拉取的「精修基准集」，必须保持可写；只有当你想让中枢刻意累积全量并集时，才把它们设成 0。</p>

<h3 class="relative group">3.3 三个真 bug 与修法
    <div id="33-三个真-bug-与修法" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#33-%e4%b8%89%e4%b8%aa%e7%9c%9f-bug-%e4%b8%8e%e4%bf%ae%e6%b3%95" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><strong>(a) heredoc 独占 stdin，远端数据静默丢失。</strong> 第一版合并写的是 <code>ssh host &quot;python - dst &lt;&lt;'PYEOF' … sys.stdin.read() … PYEOF&quot;</code>：heredoc 把 stdin 整个占走，python 从 stdin 读完自己的程序之后，<code>sys.stdin.read()</code> 得到的恒为空串。拉取两轮都报「added 0」，本来以为云端没有缺失，逐段比对才发现中枢 6 段确实都在、一条都没并进来，是<strong>报成功的数据丢失</strong>。修法是数据先落到接收端的临时文件，合并脚本一律两个文件参数（<code>python merge.py dst src</code>），不依赖 stdin 数据流。这个 bug 在改版时被重新引入过一次（老版本修过、重写时又写回去），2026-09-10 实测复现，所以现在脚本头部把这条教训写成注释。</p>
<p><strong>(b) askpass 只读 stdin，拿到空口令。</strong> 初版 askpass 按习惯 <code>cat</code> 了 stdin，但 ssh 是把提示作为 <code>argv[1]</code> 传的，解析不到密钥名，口令为空，结果是 <code>Permission denied (publickey)</code>。弹框止住了，连不上。修法：先读 <code>$1</code>，保留 stdin 回退给少数走管道的包装调用。</p>
<p><strong>(c) 派发式 askpass：从提示里的密钥名推出该用哪份凭据。</strong> 约定凭据名与密钥名一一对应：密钥 <code>~/.ssh/&lt;名&gt;-key</code> 或 <code>&lt;名&gt;-hermes</code>，凭据就叫 <code>hermes_ssh_&lt;名&gt;_ssh</code>。askpass 从提示的 <code>for key '…'</code> 里抽出密钥名，按这个约定推凭据名去 Windows 凭据管理器取口令，实测 8 把密钥全部命中；<code>CRED_TARGET</code> 仍可显式覆盖，但<strong>优先用提示里认出的密钥名</strong>。走 ProxyJump 时会先后出现两把密钥的提示（跳板钥与目标机钥），若强制指定优先，会把 A 机口令送给 B 钥，直接认证失败。认不出凭据的密钥一律交回 <code>git-askpass.exe</code>：保留机器原来的行为，并且绝不把 A 机的口令发给陌生主机（错口令 = 一次失败的 publickey 尝试，可能触发对端 fail2ban）。全文见文末折叠区 <code>askpass.sh</code>。</p>

<h3 class="relative group">3.4 自接线：不依赖调用者环境
    <div id="34-自接线不依赖调用者环境" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#34-%e8%87%aa%e6%8e%a5%e7%ba%bf%e4%b8%8d%e4%be%9d%e8%b5%96%e8%b0%83%e7%94%a8%e8%80%85%e7%8e%af%e5%a2%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>「不依赖调用者先导出环境变量」是硬要求：同步脚本自己检查 <code>SSH_ASKPASS</code>，只有当它为空或仍指向 <code>git-askpass</code> 时才替换成派发脚本，并补 <code>SSH_ASKPASS_REQUIRE=force</code> 与 <code>DISPLAY</code>。这样计划任务、后台进程、Agent 的终端跑起来都不会弹窗，也不依赖某个 shell 之前手动 export 过。</p>
<p>同时把 askpass 写成持久默认：<code>~/.bashrc</code> 与 <code>setx</code>（用户级环境变量，新开的 cmd 和计划任务都继承）各写一份。注意两个坑：<code>force</code> 不能省，否则 ssh 在有 tty 时默认忽略 askpass 直接在终端上问；改完 <code>.bashrc</code> 之后<strong>已经开着的 shell 仍是旧值</strong>，所以脚本必须自己接线，不能指望调用者环境。</p>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>验证分三层：</p>
<ul>
<li><strong>认证</strong>：全新登录 shell、零手工导出，直接 <code>ssh</code> 连通；askpass 对 8 把密钥全部命中，无弹窗。</li>
<li><strong>记忆</strong>：本机与中枢各 20 段 / 2198 字符、12 段 / 1305 字符，逐字符一致，无残留 <code>.deferred</code>。</li>
<li><strong>端到端</strong>：<code>--sync</code> 一圈下来，拉取侧 <code>0 覆盖 / 671 跳过 / 0 冲突</code>，记忆 <code>0 新增 / 0 延迟</code>，脚本与文档两端 md5 一致。</li>
</ul>
<p>怎么判断没解决：<code>--pull</code> 后跳过数是 0（比较器根本没看到暂存树，典型原因是路径没转成 Windows 形式）；记忆字符数超过预算；<code>ssh</code> 出现 GUI 弹窗或 <code>Permission denied (publickey)</code>。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>事项</th>
					<th>做法</th>
					<th>怎么验证</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>中枢作为规范源</td>
					<td>中枢记忆按「精修基准集」管理，预算默认与实例同值（2200/1375），保持可写；两边都能当源</td>
					<td><code>--sync</code> 记忆 <code>0 新增 / 0 延迟</code>，两端逐字符一致</td>
			</tr>
			<tr>
					<td>技能叠加合并</td>
					<td>按目录合并、保留分类树；同名文件在目的地比 mtime，较新者胜；同秒同刻且内容不同算冲突，不覆盖</td>
					<td>输出恒定三列 <code>copied skipped conflicts</code>，冲突数为 0</td>
			</tr>
			<tr>
					<td>记忆并集</td>
					<td>只补目标端缺失段，绝不覆盖；放不下预算的段延迟写入 <code>.deferred</code>，不静默溢出</td>
					<td><code>.deferred</code> 文件为空，字符数 ≤ 预算</td>
			</tr>
			<tr>
					<td>精修口径</td>
					<td>细节留技能、记忆留指针；删之前先 grep 技能库确认细节已在</td>
					<td>逐条对照「原内容 → 处理 → 去处」表</td>
			</tr>
			<tr>
					<td>凭据不弹窗</td>
					<td>口令只在系统凭据管理器；askpass 从提示推凭据名，认不出的交回系统默认</td>
					<td>全新 shell、零导出直接 <code>ssh</code> 成功，无弹窗</td>
			</tr>
			<tr>
					<td>失败要响</td>
					<td>合并/比较脚本逐份回读校验，异常非零退出，绝不静默「0 0」</td>
					<td>脚本非零退出时日志可见具体失败文件</td>
			</tr>
			<tr>
					<td>fail2ban 意识</td>
					<td>成功的连接不计数，失败的 publickey 才计数；别对同一主机短循环重试（被 tarpit 表现是 TCP 通、SSH banner 不来，持续 10–15 分钟）</td>
					<td>单次连接测试确认 banner 正常返回</td>
			</tr>
			<tr>
					<td>什么不同步</td>
					<td><code>.env</code>、<code>auth.json</code>、<code>state.db</code> 等凭据与运行时状态绝不随资产同步</td>
					<td>归档清单里没有这些文件</td>
			</tr>
			<tr>
					<td>部署端生效</td>
					<td>Docker 部署同步后重启容器加载新技能/记忆</td>
					<td><code>docker restart</code> 后技能计数正确</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这套体系里最值钱的一句话是「细节留技能、记忆留指针」。记忆是有预算的索引，技能是承载细节的正文；预算不是麻烦，是逼你维护索引质量的机制。每次并集都是一次索引审计：这条还值得留在索引里吗，还是该沉到技能里？</p>
<p>自动化里最贵的是静默成功。三个 bug 里有两个是「日志看起来成功了」：heredoc 丢数据报 added 0、路径没转换报 0 覆盖。所有合并与比较都做回读校验、失败非零退出，再小的脚本也值得这么做。</p>





<div
  id="accordion-94363ab409e25653e55ef8e4590d05f9"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>脚本全文 1/3：askpass.sh（派发式 askpass，占位符化）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="c1"># 派发式 SSH askpass（自动化用）</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   1) 从 ssh 的提示里认出是哪个密钥，再去 Windows 凭据管理器取对应口令；</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   2) 约定：密钥 ~/.ssh/&lt;name&gt;-key 或 &lt;name&gt;-hermes  &lt;-&gt;  凭据 hermes_ssh_&lt;name&gt;_ssh</span>
</span></span><span class="line"><span class="cl"><span class="c1">#      （&lt;示例&gt;-hermes -&gt; hermes_ssh_&lt;示例&gt;_ssh）；</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   3) CRED_TARGET 可强制指定（~/bin/&lt;host&gt; 包装脚本按老方式工作，仍生效）；</span>
</span></span><span class="line"><span class="cl"><span class="c1">#      但【优先用从提示里认出的密钥名】—— 走 ProxyJump 时会先后出现两把密钥的口令提示</span>
</span></span><span class="line"><span class="cl"><span class="c1">#      （跳板钥与目标机密钥），若 CRED_TARGET 优先会把 A 机口令送给 B 钥，直接认证失败。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#      derived-first 后，包装脚本只设 CRED_TARGET 的老用法仍然有效（提示里没密钥名时回退到它）。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   4) 认不出对应凭据时什么都不输出、退出 1 —— 绝不把 A 机器的口令送给 B 主机</span>
</span></span><span class="line"><span class="cl"><span class="c1">#      （错口令会变成一次失败的 publickey 尝试，可能触发对方 fail2ban）。</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -u
</span></span><span class="line"><span class="cl"><span class="nv">READ</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">LOCALAPPDATA</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/AppData/Local</span><span class="si">}</span><span class="s2">/hermes/scripts/credread.ps1&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">prompt</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>                                           <span class="c1"># ssh 把提示作为【第一个参数】传入</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$prompt</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="nv">prompt</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>cat 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="k">)</span><span class="s2">&#34;</span>    <span class="c1"># 少数实现/包装脚本走 stdin</span>
</span></span><span class="line"><span class="cl"><span class="nv">key</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$prompt</span><span class="s2">&#34;</span> <span class="p">|</span> sed -n <span class="s2">&#34;s/.*for key &#39;\([^&#39;]*\)&#39;.*/\1/p&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">base</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="si">${</span><span class="nv">key</span><span class="p">%.pub</span><span class="si">}</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">derived</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$base</span><span class="s2">&#34;</span> in
</span></span><span class="line"><span class="cl">  &lt;示例&gt;-hermes<span class="o">)</span> <span class="nv">derived</span><span class="o">=</span><span class="s2">&#34;hermes_ssh_&lt;示例&gt;_ssh&#34;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  *-key<span class="o">)</span>     <span class="nv">derived</span><span class="o">=</span><span class="s2">&#34;hermes_ssh_</span><span class="si">${</span><span class="nv">base</span><span class="p">%-key</span><span class="si">}</span><span class="s2">_ssh&#34;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  *-hermes<span class="o">)</span>  <span class="nv">derived</span><span class="o">=</span><span class="s2">&#34;hermes_ssh_</span><span class="si">${</span><span class="nv">base</span><span class="p">%-hermes</span><span class="si">}</span><span class="s2">_ssh&#34;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="cl"><span class="k">esac</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 提示里认不出密钥(老包装脚本直接调用) -&gt; 沿用历史默认, 否则对陌生主机什么都不发</span>
</span></span><span class="line"><span class="cl"><span class="nv">default</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> -z <span class="s2">&#34;</span><span class="nv">$base</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">default</span><span class="o">=</span><span class="s2">&#34;hermes_ssh_&lt;示例&gt;_ssh&#34;</span>   <span class="c1"># 兜底：改成你自己的常见密钥名</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> t in <span class="s2">&#34;</span><span class="nv">$derived</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">CRED_TARGET</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$default</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$t</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">pw</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>powershell -NoProfile -ExecutionPolicy Bypass -File <span class="s2">&#34;</span><span class="nv">$READ</span><span class="s2">&#34;</span> -Target <span class="s2">&#34;</span><span class="nv">$t</span><span class="s2">&#34;</span> 2&gt;/dev/null<span class="k">)</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">pw</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$pw</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$pw</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">exit</span> 0<span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 不是我们的密钥（没有对应凭据）: 交回 git-bash 原本的 GUI askpass, 行为与从前一致</span>
</span></span><span class="line"><span class="cl"><span class="nv">LEGACY</span><span class="o">=</span><span class="s1">&#39;/mingw64/bin/git-askpass.exe&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -x <span class="s2">&#34;</span><span class="nv">$LEGACY</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$prompt</span><span class="s2">&#34;</span> <span class="p">|</span> <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$LEGACY</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>

  </div>
</details>





  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>脚本全文 2/3：apply-newest-wins.py（最新者胜比较器）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;Apply a staged tree onto a destination with newest-wins semantics.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">The sync script stages a whole tree (tar preserves mtimes) and then runs this
</span></span></span><span class="line"><span class="cl"><span class="s2">applier at the DESTINATION, so the comparison happens where both sides are
</span></span></span><span class="line"><span class="cl"><span class="s2">visible:
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">    apply-newest-wins.py &lt;staged_dir&gt; &lt;dest_dir&gt; [owner[:group]]
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">A file is copied only when the destination lacks it or the staged copy is
</span></span></span><span class="line"><span class="cl"><span class="s2">strictly newer. Everything else is left alone and counted as skipped. This is
</span></span></span><span class="line"><span class="cl"><span class="s2">what stops a sync from trampling work done on either side — plain
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;same name wins&#34; destroys whatever the losing side wrote.
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">stdout is always &#34;&lt;copied&gt; &lt;skipped&gt; &lt;conflicts&gt;&#34; (shell-readable).
</span></span></span><span class="line"><span class="cl"><span class="s2">Exit status: 0 = clean, 3 = some file failed to copy or failed read-back,
</span></span></span><span class="line"><span class="cl"><span class="s2">             4 = the staged dir is missing (a silent no-op is never acceptable).
</span></span></span><span class="line"><span class="cl"><span class="s2">With an owner argument (remote pushes only) the touched tree is chowned back to
</span></span></span><span class="line"><span class="cl"><span class="s2">the container user afterwards, because tar unpacked as root restores the packer&#39;s
</span></span></span><span class="line"><span class="cl"><span class="s2">uid verbatim (a Windows MSYS uid like 197609 lands on the VPS and makes the tree
</span></span></span><span class="line"><span class="cl"><span class="s2">unwritable for the container user).
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;conflicts&#34; counts files whose mtime is *identical* on both sides but whose
</span></span></span><span class="line"><span class="cl"><span class="s2">content differs — tar stores whole seconds, so this is the one case the mtime
</span></span></span><span class="line"><span class="cl"><span class="s2">rule cannot decide. Nothing is overwritten; the count tells a human to look.
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">__future__</span> <span class="kn">import</span> <span class="n">annotations</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">hashlib</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">shutil</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">EXIT_OK</span><span class="p">,</span> <span class="n">EXIT_FAILED</span><span class="p">,</span> <span class="n">EXIT_NO_STAGE</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">4</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">digest</span><span class="p">(</span><span class="n">path</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">chunk</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">h</span> <span class="o">=</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">md5</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="s2">&#34;rb&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">block</span> <span class="o">=</span> <span class="n">f</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">chunk</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">block</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">break</span>
</span></span><span class="line"><span class="cl">            <span class="n">h</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">block</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">h</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">same_content</span><span class="p">(</span><span class="n">a</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">b</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">getsize</span><span class="p">(</span><span class="n">a</span><span class="p">)</span> <span class="o">!=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">getsize</span><span class="p">(</span><span class="n">b</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">digest</span><span class="p">(</span><span class="n">a</span><span class="p">)</span> <span class="o">==</span> <span class="n">digest</span><span class="p">(</span><span class="n">b</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">OSError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">apply_newest_wins</span><span class="p">(</span><span class="n">staged</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">dest</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="nb">int</span><span class="p">,</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]]:</span>
</span></span><span class="line"><span class="cl">    <span class="n">copied</span> <span class="o">=</span> <span class="n">skipped</span> <span class="o">=</span> <span class="n">conflicts</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="n">problems</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">root</span><span class="p">,</span> <span class="n">_dirs</span><span class="p">,</span> <span class="n">files</span> <span class="ow">in</span> <span class="n">os</span><span class="o">.</span><span class="n">walk</span><span class="p">(</span><span class="n">staged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">rel</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">relpath</span><span class="p">(</span><span class="n">root</span><span class="p">,</span> <span class="n">staged</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">name</span> <span class="ow">in</span> <span class="n">files</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">src</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">root</span><span class="p">,</span> <span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">target</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">dest</span><span class="p">,</span> <span class="n">name</span><span class="p">)</span> <span class="k">if</span> <span class="n">rel</span> <span class="o">==</span> <span class="s2">&#34;.&#34;</span> <span class="k">else</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="n">dest</span><span class="p">,</span> <span class="n">rel</span><span class="p">,</span> <span class="n">name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">exists</span><span class="p">(</span><span class="n">target</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                    <span class="n">smt</span><span class="p">,</span> <span class="n">dmt</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">getmtime</span><span class="p">(</span><span class="n">src</span><span class="p">),</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">getmtime</span><span class="p">(</span><span class="n">target</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                    <span class="k">if</span> <span class="n">dmt</span> <span class="o">&gt;</span> <span class="n">smt</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                        <span class="n">skipped</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">                        <span class="k">continue</span>
</span></span><span class="line"><span class="cl">                    <span class="k">if</span> <span class="n">dmt</span> <span class="o">==</span> <span class="n">smt</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                        <span class="c1"># 同秒同刻：一样的算跳过，不一样的判为歧义（不覆盖，报数）</span>
</span></span><span class="line"><span class="cl">                        <span class="k">if</span> <span class="n">same_content</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">target</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                            <span class="n">skipped</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">                        <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                            <span class="n">conflicts</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">                        <span class="k">continue</span>
</span></span><span class="line"><span class="cl">                <span class="n">os</span><span class="o">.</span><span class="n">makedirs</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">dirname</span><span class="p">(</span><span class="n">target</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;.&#34;</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="n">shutil</span><span class="o">.</span><span class="n">copy2</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">target</span><span class="p">)</span>          <span class="c1"># copy2 保留 mtime</span>
</span></span><span class="line"><span class="cl">                <span class="c1"># 回读校验：不信 copy 的返回值（静默半写是我们要消灭的故障类）</span>
</span></span><span class="line"><span class="cl">                <span class="k">if</span> <span class="ow">not</span> <span class="n">same_content</span><span class="p">(</span><span class="n">src</span><span class="p">,</span> <span class="n">target</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                    <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;verify-mismatch: </span><span class="si">{</span><span class="n">target</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                    <span class="n">copied</span> <span class="o">+=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">            <span class="k">except</span> <span class="ne">OSError</span> <span class="k">as</span> <span class="n">exc</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;  ! </span><span class="si">{</span><span class="n">target</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">exc</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="n">problems</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">target</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">exc</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">copied</span><span class="p">,</span> <span class="n">skipped</span><span class="p">,</span> <span class="n">conflicts</span><span class="p">,</span> <span class="n">problems</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">3</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="vm">__doc__</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()[</span><span class="mi">0</span><span class="p">],</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">staged</span><span class="p">,</span> <span class="n">dest</span> <span class="o">=</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">owner</span> <span class="o">=</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span> <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">3</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">isdir</span><span class="p">(</span><span class="n">staged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;! staged dir 不存在: </span><span class="si">{</span><span class="n">staged</span><span class="si">}</span><span class="s2">（比较器不做任何事是故障, 故非零退出）&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;0 0 0&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">EXIT_NO_STAGE</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">os</span><span class="o">.</span><span class="n">makedirs</span><span class="p">(</span><span class="n">dest</span><span class="p">,</span> <span class="n">exist_ok</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">copied</span><span class="p">,</span> <span class="n">skipped</span><span class="p">,</span> <span class="n">conflicts</span><span class="p">,</span> <span class="n">problems</span> <span class="o">=</span> <span class="n">apply_newest_wins</span><span class="p">(</span><span class="n">staged</span><span class="p">,</span> <span class="n">dest</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">owner</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="s2">&#34;chown&#34;</span><span class="p">,</span> <span class="s2">&#34;-R&#34;</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">dest</span><span class="p">],</span> <span class="n">check</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">copied</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">skipped</span><span class="si">}</span><span class="s2"> </span><span class="si">{</span><span class="n">conflicts</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">problems</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;failed=</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">problems</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">problems</span><span class="p">[:</span><span class="mi">20</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;  - </span><span class="si">{</span><span class="n">p</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">problems</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">20</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;  ... 其余 </span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">problems</span><span class="p">)</span> <span class="o">-</span> <span class="mi">20</span><span class="si">}</span><span class="s2"> 条已省略&#34;</span><span class="p">,</span> <span class="n">file</span><span class="o">=</span><span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">EXIT_FAILED</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">EXIT_OK</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">main</span><span class="p">())</span></span></span></code></pre></div></div>

  </div>
</details>





  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>脚本全文 3/3：sync-hermes-to-vps.sh（同步主脚本，端点与文件名已占位符化）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># sync-hermes-to-vps.sh — 把本地 Hermes 的 skills + memory 与 VPS 同步</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 用法:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   ./sync-hermes-to-vps.sh            # 仅打包本地资产到 staging 目录(预览)</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   ./sync-hermes-to-vps.sh --push     # 本地 -&gt; VPS (最新者胜技能 + 追加合并记忆)</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   ./sync-hermes-to-vps.sh --pull     # VPS -&gt; 本地 (最新者胜技能 + 追加合并记忆)</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   ./sync-hermes-to-vps.sh --sync     # 双向一次跑完 (先 pull 再 push, 推荐日常使用)</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Windows 上推荐用同目录的 sync-hermes-to-vps.cmd 调用: 它负责填好端点、askpass</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 与 profile, 并规避 CMD/PowerShell 把 &#34;$LOCALAPPDATA/...&#34; 里的反斜杠吃掉的问题。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 实测要点(2026-08 ~ 2026-09, Windows git-bash &lt;-&gt; Ubuntu Docker VPS):</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - Windows git-bash 默认没有 rsync, 所以用 tar 流式经 ssh 双向传输, 跨平台可靠。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - VPS 若是 Docker 部署, HERMES_HOME 通常是 bind-mount 的 host 路径</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     (宿主 bind-mount 路径 = 容器内数据目录), 不是 ~/.hermes。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - 技能同步是「最新者胜」: 由比较器在目标端逐一比 mtime(整秒),</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     只覆盖更旧的同名文件; 目标端独有/更新的文件一律保留。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     collect_local 必须用 cp -a: 普通 cp -r 不保留 mtime, 会让本地文件全部</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     &#34;看起来更新&#34;从而反过来覆盖目标端。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - **同步是「叠加」语义, 不是镜像**: 一端删除的文件/技能不会在另一端被删除。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     清理必须显式做(`--list-only-here` 只报数, 不删)。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - 记忆同步是「追加合并」(MEMORY.md 与 USER.md 同一套): 基于 § 段落去重追加,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     绝不覆盖目标端已有记忆。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - **记忆合并的 python 一律用「文件参数」传 dst/src, 绝不走 stdin 数据流。**</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     `cat src | ssh host &#34;python3 - dst &lt;&lt;&#39;EOF&#39;&#34;` 里 heredoc 独占 stdin, python</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     读完程序后 sys.stdin.read() 恒为空 → 静默&#34;新增 0 段&#34;(2026-09-10 实测复现)。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - 合并后校验字符数: 超过实例预算(~2200/1375)时 Hermes 会拒绝后续记忆写入,</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     所以这里直接告警, 不留给下次出问题才发现。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - 每次运行用独立的 run 目录与远端暂存名(含 run id): 两台机器/两个进程同时跑</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     不会互删暂存、也不会把别人的 staging 覆盖到自己目标上。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - 拉取时排除 skills 根目录的 runtime 元数据(.bundled_manifest/.curator_*/</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     .usage.json/.curator_backups/.hub) 与各机 python 缓存。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - SSH_PORT 留空 = 不传 -p; 用 ~/.ssh/config 的 Host 别名时留空, 让别名决定端口。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   - 本机 python3 可能是 Store 占位(非真实解释器): 必须探测可用的解释器。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ============ CONFIG — 改成你自己的 ============</span>
</span></span><span class="line"><span class="cl"><span class="c1"># VPS SSH 登录。推荐填 ~/.ssh/config 里的 Host 别名(如 hub),</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 这样 hostname/port/密钥/跳板都由 config 决定, 命令行不要再用 -i/-J 覆盖。</span>
</span></span><span class="line"><span class="cl"><span class="nv">VPS_USER_HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">VPS_USER_HOST</span><span class="k">:-</span><span class="nv">YOUR_SSH_ALIAS_OR_root</span><span class="p">@YOUR_VPS_HOST</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># VPS 上 Hermes 的 HERMES_HOME(host 视角)。Docker 部署 = bind-mount 的 host 路径。</span>
</span></span><span class="line"><span class="cl"><span class="nv">VPS_HERMES_HOME</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">VPS_HERMES_HOME</span><span class="k">:-</span><span class="nv">YOUR_VPS_HERMES_HOME</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># SSH 端口: 非 22 且**不用** ssh 别名时填。留空 = 不加 -p(交给 ssh/config 决定)。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 注意: 用别名时若这里填了别的端口, -p 会覆盖 config 的 Port。</span>
</span></span><span class="line"><span class="cl"><span class="nv">SSH_PORT</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_PORT</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># SSH 私钥路径(可选; 从密码管理器导出的 .pem/.key, 用户在终端提供)。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 「密钥带口令 + 没有 agent」时: 自己接好 askpass, 不依赖调用者的环境,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 也不允许 ssh 退回终端/GUI 提示(自动化里那样就是挂死)。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 派发脚本按提示里的密钥名去 Windows 凭据管理器取口令(约定 hermes_ssh_&lt;name&gt;_ssh)。</span>
</span></span><span class="line"><span class="cl"><span class="nv">ASKPASS_SH</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">LOCALAPPDATA</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/AppData/Local</span><span class="si">}</span><span class="s2">/hermes/scripts/askpass.sh&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -x <span class="s2">&#34;</span><span class="nv">$ASKPASS_SH</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> -f <span class="s2">&#34;</span><span class="nv">$ASKPASS_SH</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ASKPASS</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> in
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;</span><span class="p">|</span>*git-askpass*<span class="o">)</span> <span class="nb">export</span> <span class="nv">SSH_ASKPASS</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$ASKPASS_SH</span><span class="s2">&#34;</span> <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  <span class="k">esac</span>
</span></span><span class="line"><span class="cl">  <span class="nb">export</span> <span class="nv">SSH_ASKPASS_REQUIRE</span><span class="o">=</span>force
</span></span><span class="line"><span class="cl">  : <span class="s2">&#34;</span><span class="si">${</span><span class="nv">DISPLAY</span><span class="p">:=:</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">export</span> DISPLAY
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 用 ssh 别名时留空, 让 config 的 IdentityFile(可能是带口令的, 配 askpass)生效。</span>
</span></span><span class="line"><span class="cl"><span class="nv">SSH_KEY</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_KEY</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 跳板机/堡垒机(可选): 形如 user@jump-host 或 user@jump-host:port。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># VPS 只允许经跳板访问时必须填; 留空 = 直连 VPS。</span>
</span></span><span class="line"><span class="cl"><span class="nv">SSH_JUMP</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_JUMP</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># SSH 连接复用(ControlMaster)套接字: 默认关闭。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 实测(2026-09, git-bash OpenSSH + ProxyJump): 复用与跳板组合下会</span>
</span></span><span class="line"><span class="cl"><span class="c1"># &#34;mux_client_request_session: read from master failed&#34; 直接打断 tar 流,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 所以宁可用普通连接。要开启就显式设 SSH_CONTROL_PATH。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 注意: fail2ban 只统计**认证失败**, 成功连接多开不触发, 关掉复用是安全的。</span>
</span></span><span class="line"><span class="cl"><span class="nv">SSH_CONTROL_PATH</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_CONTROL_PATH</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 本地 HERMES_HOME(Windows git-bash 下是 $HOME/AppData/Local/hermes)</span>
</span></span><span class="line"><span class="cl"><span class="nv">LOCAL_HERMES_HOME</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">LOCAL_HERMES_HOME</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/AppData/Local/hermes</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 可选: 把某个本地 profile(如第二个档)也一起同步。留空则只同步 default。</span>
</span></span><span class="line"><span class="cl"><span class="nv">EXTRA_PROFILE</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">EXTRA_PROFILE</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># staging 目录, 打包产物与每次运行的 run 目录放这里</span>
</span></span><span class="line"><span class="cl"><span class="nv">STAGING</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">STAGING</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/hermes-sync-staging</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># staging 保留最近几次运行(避免系统盘无限增长): run 目录与 tar 包各留这么多份</span>
</span></span><span class="line"><span class="cl"><span class="nv">KEEP_RUNS</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">KEEP_RUNS</span><span class="k">:-</span><span class="nv">3</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 目标端 Hermes 运行用户的 UID/GID(容器内 hermes 用户通常是 1000)。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 关键: Windows 侧 tar 会把 MSYS 的合成 uid(如 197609)记进归档, 远端 root 解包时会</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 原样落盘, 导致技能树变成只读, 容器内 hermes 无法写 -&gt; 必须解包后 chown 回该值。</span>
</span></span><span class="line"><span class="cl"><span class="nv">TARGET_UID</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">TARGET_UID</span><span class="k">:-</span><span class="nv">1000</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">TARGET_GID</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">TARGET_GID</span><span class="k">:-</span><span class="nv">1000</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 实例的记忆预算(字符): 拉取合并到本机时超出就&#34;延迟&#34;而不是溢出(Hermes 会拒绝超预算写入)</span>
</span></span><span class="line"><span class="cl"><span class="nv">MEMORY_BUDGET</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">MEMORY_BUDGET</span><span class="k">:-</span><span class="nv">2200</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">USER_BUDGET</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">USER_BUDGET</span><span class="k">:-</span><span class="nv">1375</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 汇聚端(VPS)的记忆预算: 默认与实例同值 —— 中枢记忆是各机拉取的「精修基准集」,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 必须保持可写; 设 0 = 不限, 只在你故意让中枢累积全量并集时用。超出同样记延迟, 不会丢。</span>
</span></span><span class="line"><span class="cl"><span class="nv">HUB_MEMORY_BUDGET</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">HUB_MEMORY_BUDGET</span><span class="k">:-</span><span class="nv">2200</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">HUB_USER_BUDGET</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">HUB_USER_BUDGET</span><span class="k">:-</span><span class="nv">1375</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 拉取时排除的 runtime 元数据(skills 根目录, 各机自有状态) + 各机 python 缓存</span>
</span></span><span class="line"><span class="cl"><span class="nv">EXCLUDES</span><span class="o">=(</span>--exclude<span class="o">=</span>./.bundled_manifest --exclude<span class="o">=</span>./.curator_ledger.jsonl --exclude<span class="o">=</span>./.curator_state
</span></span><span class="line"><span class="cl">          --exclude<span class="o">=</span>./.usage.json --exclude<span class="o">=</span>./.usage.json.lock --exclude<span class="o">=</span>./.curator_backups
</span></span><span class="line"><span class="cl">          --exclude<span class="o">=</span>./.hub --exclude<span class="o">=</span>./.sync-stage --exclude<span class="o">=</span>./.sync-mem-src.tmp
</span></span><span class="line"><span class="cl">          --exclude<span class="o">=</span>./.sync-run- --exclude<span class="o">=</span>__pycache__<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 本机 python 解释器: 显式覆盖用 PYTHON=&lt;path&gt;。默认探测一个真能跑的解释器</span>
</span></span><span class="line"><span class="cl"><span class="c1"># (本机 python3 可能是 Store 占位, 不探测会静默不执行)。</span>
</span></span><span class="line"><span class="cl"><span class="nv">PYTHON</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">PYTHON</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ==============================================</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">NOW</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>date +%Y%m%d_%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 每次运行唯一的 run id: 本地 run 目录与远端暂存名都带它 -&gt; 并发不互踩</span>
</span></span><span class="line"><span class="cl"><span class="nv">RUNID</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>date +%s<span class="k">)</span><span class="s2">-</span><span class="nv">$$</span><span class="s2">-</span><span class="k">$(</span>hostname 2&gt;/dev/null <span class="p">|</span> tr -cd <span class="s1">&#39;A-Za-z0-9&#39;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">RUNDIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$STAGING</span><span class="s2">/run-</span><span class="nv">$NOW</span><span class="s2">-</span><span class="nv">$RUNID</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">TARBALL</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$STAGING</span><span class="s2">/hermes-assets-</span><span class="nv">$NOW</span><span class="s2">-</span><span class="nv">$RUNID</span><span class="s2">.tar.gz&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 「最新者胜」比较器：两边都看得见 mtime 的那一端才跑它（见各 push_/pull_ 函数）</span>
</span></span><span class="line"><span class="cl"><span class="nv">SCRIPT_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BASH_SOURCE</span><span class="p">[0]</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">APPLIER</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$SCRIPT_DIR</span><span class="s2">/apply-newest-wins.py&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 原生 Windows python 打不开 MSYS 风格路径(/c/...), 传参前必须转成 C:\...</span>
</span></span><span class="line"><span class="cl"><span class="nv">APPLIER_WIN</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$APPLIER</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$APPLIER</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 远端暂存目录：先整棵传上去，再由比较器逐文件决定是否覆盖。带 RUNID 防并发串扰。</span>
</span></span><span class="line"><span class="cl"><span class="nv">REMOTE_STAGE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/.sync-run-</span><span class="nv">$RUNID</span><span class="s2">.stage&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">REMOTE_MEMSRC</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/.sync-run-</span><span class="nv">$RUNID</span><span class="s2">.memsrc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">REMOTE_MERGEPY</span><span class="o">=</span><span class="s2">&#34;/tmp/hermes-md-merge-</span><span class="nv">$RUNID</span><span class="s2">.py&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 本地/远端共用的记忆合并脚本（以文件参数取数据, 见头部说明）</span>
</span></span><span class="line"><span class="cl"><span class="nv">MERGEPY</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/md-merge.py&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">MERGEPY_WIN</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$MERGEPY</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$MERGEPY</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">REMOTE_PY</span><span class="o">=</span><span class="s2">&#34;python3&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">MERGEPY_BODY</span><span class="o">=</span><span class="s1">&#39;import sys, re, os
</span></span></span><span class="line"><span class="cl"><span class="s1">dst, src = sys.argv[1], sys.argv[2]
</span></span></span><span class="line"><span class="cl"><span class="s1">budget = int(sys.argv[3]) if len(sys.argv) &gt; 3 else 0        # 0 = 不限(汇聚端当档案库)
</span></span></span><span class="line"><span class="cl"><span class="s1">norm = lambda s: re.sub(r&#34;\s+&#34;, &#34; &#34;, s).strip()
</span></span></span><span class="line"><span class="cl"><span class="s1">def segs(t):
</span></span></span><span class="line"><span class="cl"><span class="s1">    return [norm(x) for x in re.split(r&#34;(?m)^[ \t]*\u00a7[ \t]*$&#34;, t)] if t.strip() else []
</span></span></span><span class="line"><span class="cl"><span class="s1">try:
</span></span></span><span class="line"><span class="cl"><span class="s1">    existing = open(dst, encoding=&#34;utf-8&#34;, errors=&#34;replace&#34;).read()
</span></span></span><span class="line"><span class="cl"><span class="s1">except FileNotFoundError:
</span></span></span><span class="line"><span class="cl"><span class="s1">    existing = &#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">existing_segs = [s for s in segs(existing) if s]
</span></span></span><span class="line"><span class="cl"><span class="s1">cores = {s[:60] for s in existing_segs}
</span></span></span><span class="line"><span class="cl"><span class="s1">out = existing.rstrip(&#34;\n&#34;)
</span></span></span><span class="line"><span class="cl"><span class="s1">added = 0
</span></span></span><span class="line"><span class="cl"><span class="s1">deferred = []
</span></span></span><span class="line"><span class="cl"><span class="s1">deferred_full = []
</span></span></span><span class="line"><span class="cl"><span class="s1">for s in segs(open(src, encoding=&#34;utf-8&#34;, errors=&#34;replace&#34;).read()):
</span></span></span><span class="line"><span class="cl"><span class="s1">    if not s:
</span></span></span><span class="line"><span class="cl"><span class="s1">        continue
</span></span></span><span class="line"><span class="cl"><span class="s1">    c = s[:60]
</span></span></span><span class="line"><span class="cl"><span class="s1">    if c in cores or any(c in r for r in existing_segs):
</span></span></span><span class="line"><span class="cl"><span class="s1">        continue
</span></span></span><span class="line"><span class="cl"><span class="s1">    candidate = (out + (&#34;\n\u00a7\n&#34; if out.strip() else &#34;&#34;) + s).strip()
</span></span></span><span class="line"><span class="cl"><span class="s1">    if budget and len(candidate) + 1 &gt; budget:
</span></span></span><span class="line"><span class="cl"><span class="s1">        deferred.append(s[:48])         # 目的地预算优先: 不追加, 但明确报出来
</span></span></span><span class="line"><span class="cl"><span class="s1">        deferred_full.append(s)
</span></span></span><span class="line"><span class="cl"><span class="s1">        continue
</span></span></span><span class="line"><span class="cl"><span class="s1">    out = candidate
</span></span></span><span class="line"><span class="cl"><span class="s1">    cores.add(c)
</span></span></span><span class="line"><span class="cl"><span class="s1">    added += 1
</span></span></span><span class="line"><span class="cl"><span class="s1">if not existing.strip():
</span></span></span><span class="line"><span class="cl"><span class="s1">    out = out.lstrip(&#34;\n\u00a7&#34;)
</span></span></span><span class="line"><span class="cl"><span class="s1">out = out.strip() + &#34;\n&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">if existing.strip() and not out.startswith(existing.rstrip(&#34;\n&#34;)):
</span></span></span><span class="line"><span class="cl"><span class="s1">    print(&#34;WARN: 目标文件在我读取后被别人改过(并发写入), 本次结果可能少一段&#34;, file=sys.stderr)
</span></span></span><span class="line"><span class="cl"><span class="s1">with open(dst, &#34;w&#34;, encoding=&#34;utf-8&#34;, newline=&#34;\n&#34;) as f:
</span></span></span><span class="line"><span class="cl"><span class="s1">    f.write(out)
</span></span></span><span class="line"><span class="cl"><span class="s1">side = dst + &#34;.deferred&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">if deferred_full:
</span></span></span><span class="line"><span class="cl"><span class="s1">    with open(side, &#34;w&#34;, encoding=&#34;utf-8&#34;, newline=&#34;\n&#34;) as f:
</span></span></span><span class="line"><span class="cl"><span class="s1">        f.write(&#34;\n\u00a7\n&#34;.join(deferred_full) + &#34;\n&#34;)
</span></span></span><span class="line"><span class="cl"><span class="s1">else:
</span></span></span><span class="line"><span class="cl"><span class="s1">    try:
</span></span></span><span class="line"><span class="cl"><span class="s1">        os.remove(side)
</span></span></span><span class="line"><span class="cl"><span class="s1">    except OSError:
</span></span></span><span class="line"><span class="cl"><span class="s1">        pass
</span></span></span><span class="line"><span class="cl"><span class="s1">print(&#34;added segments:&#34;, added, &#34;| deferred:&#34;, len(deferred), &#34;| chars:&#34;, len(out.strip()) + 1)
</span></span></span><span class="line"><span class="cl"><span class="s1">if deferred:
</span></span></span><span class="line"><span class="cl"><span class="s1">    print(&#34;DEFERRED(超出预算未合并, 全文见 &#34; + side + &#34;): &#34; + &#34; || &#34;.join(deferred[:5]), file=sys.stderr)
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 选一个真能跑的 python ----------</span>
</span></span><span class="line"><span class="cl">pick_python<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$PYTHON</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="k">return</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> c
</span></span><span class="line"><span class="cl">  <span class="k">for</span> c in python3 python py<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">command</span> -v <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">&amp;&amp;</span> <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span> -c <span class="s1">&#39;import sys&#39;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span>1<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      <span class="nv">PYTHON</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">return</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;!! 找不到可用的本地 python(试过 python3/python/py); 可用 PYTHON=&lt;path&gt; 指定&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">ensure_helpers<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  mkdir -p <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">printf</span> <span class="s1">&#39;%s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$MERGEPY_BODY</span><span class="s2">&#34;</span> &gt; <span class="s2">&#34;</span><span class="nv">$MERGEPY</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  pick_python
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 字符数: 本地文件用 $PYTHON, 远端文件用远端 python3（wc -m 受 locale 影响, 不用）</span>
</span></span><span class="line"><span class="cl">local_chars<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;</span><span class="nv">$PYTHON</span><span class="s2">&#34;</span> -c <span class="s2">&#34;import sys;print(len(open(sys.argv[1],encoding=&#39;utf-8&#39;,errors=&#39;replace&#39;).read()))&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">remote_chars<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="nv">$REMOTE_PY</span><span class="s2"> -c &#39;import sys;print(len(open(sys.argv[1],encoding=\&#34;utf-8\&#34;,errors=\&#34;replace\&#34;).read()))&#39; &#39;</span><span class="nv">$1</span><span class="s2">&#39;&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">check_budget<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 用法: check_budget &lt;label&gt; &lt;chars&gt; &lt;budget&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">label</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="nv">chars</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$2</span><span class="s2">&#34;</span> <span class="nv">budget</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$3</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$chars</span><span class="s2">&#34;</span> -gt <span class="s2">&#34;</span><span class="nv">$budget</span><span class="s2">&#34;</span> <span class="o">]</span> 2&gt;/dev/null<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    !! </span><span class="nv">$label</span><span class="s2"> 现有 </span><span class="nv">$chars</span><span class="s2"> 字符, 超过实例预算 </span><span class="nv">$budget</span><span class="s2"> —— 该实例会拒绝后续记忆写入;&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;       需要在中枢/本机手工精简(§ 段落合并), 见 SKILL.md「Keep the hub&#39;s MEMORY.md curated」&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    </span><span class="nv">$label</span><span class="s2"> 字符数 </span><span class="nv">$chars</span><span class="s2"> (预算 </span><span class="nv">$budget</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">validate_config<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">bad</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> in *YOUR_*<span class="p">|</span>*<span class="s1">&#39;&lt;&#39;</span>*<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;!! VPS_USER_HOST 仍是占位符: </span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nv">bad</span><span class="o">=</span>1<span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">&#34;</span> in *YOUR_*<span class="p">|</span>*<span class="s1">&#39;&lt;&#39;</span>*<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;!! VPS_HERMES_HOME 仍是占位符: </span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nv">bad</span><span class="o">=</span>1<span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$bad</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">1</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;   模板把端点占位符化了: 先本地化填回本机真值,&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;   或用 VPS_USER_HOST=/VPS_HERMES_HOME= 环境变量显式给值。&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">2</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 远端暂存/临时文件的兜底清理(异常退出也不留垃圾)</span>
</span></span><span class="line"><span class="cl">cleanup<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl">  <span class="nb">trap</span> - EXIT
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">REMOTE_STAGE</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[*]</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      <span class="s2">&#34;rm -rf &#39;</span><span class="nv">$REMOTE_STAGE</span><span class="s2">&#39; &#39;</span><span class="nv">$REMOTE_MEMSRC</span><span class="s2">&#39; &#39;</span><span class="nv">$REMOTE_MERGEPY</span><span class="s2">&#39;&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">prune_old_runs<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  ls -1dt <span class="s2">&#34;</span><span class="nv">$STAGING</span><span class="s2">&#34;</span>/run-* 2&gt;/dev/null <span class="p">|</span> tail -n +<span class="k">$((</span>KEEP_RUNS <span class="o">+</span> <span class="m">1</span><span class="k">))</span> <span class="p">|</span> xargs -r rm -rf
</span></span><span class="line"><span class="cl">  ls -1t  <span class="s2">&#34;</span><span class="nv">$STAGING</span><span class="s2">&#34;</span>/hermes-assets-*.tar.gz 2&gt;/dev/null <span class="p">|</span> tail -n +<span class="k">$((</span>KEEP_RUNS <span class="o">+</span> <span class="m">1</span><span class="k">))</span> <span class="p">|</span> xargs -r rm -f
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 构造 SSH 参数 ----------</span>
</span></span><span class="line"><span class="cl"><span class="nv">SSH_ARGS</span><span class="o">=(</span>-o <span class="nv">StrictHostKeyChecking</span><span class="o">=</span>accept-new -o <span class="nv">ConnectTimeout</span><span class="o">=</span>20<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$SSH_PORT</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="nv">SSH_ARGS</span><span class="o">+=(</span> -p <span class="s2">&#34;</span><span class="nv">$SSH_PORT</span><span class="s2">&#34;</span> <span class="o">)</span><span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$SSH_JUMP</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="nv">SSH_ARGS</span><span class="o">+=(</span> -J <span class="s2">&#34;</span><span class="nv">$SSH_JUMP</span><span class="s2">&#34;</span> <span class="o">)</span><span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$SSH_KEY</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="nv">SSH_ARGS</span><span class="o">+=(</span> -i <span class="s2">&#34;</span><span class="nv">$SSH_KEY</span><span class="s2">&#34;</span> <span class="o">)</span><span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$SSH_CONTROL_PATH</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  mkdir -p <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_CONTROL_PATH</span><span class="p">%%%*</span><span class="si">}</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl">  <span class="nv">SSH_ARGS</span><span class="o">+=(</span> -o <span class="nv">ControlMaster</span><span class="o">=</span>auto -o <span class="s2">&#34;ControlPath=</span><span class="nv">$SSH_CONTROL_PATH</span><span class="s2">&#34;</span> -o <span class="nv">ControlPersist</span><span class="o">=</span><span class="m">120</span> <span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">validate_config
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> cleanup EXIT
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;==&gt; 本地 HERMES_HOME: </span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> -d <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> mkdir -p <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">prune_old_runs
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 收集 skills (default) ----------</span>
</span></span><span class="line"><span class="cl">collect_local<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 收集 skills (default) ...&#34;</span>
</span></span><span class="line"><span class="cl">  rm -rf <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/skills&#34;</span>
</span></span><span class="line"><span class="cl">  mkdir -p <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/skills&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> -d <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/skills&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="o">(</span> <span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/skills&#34;</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      find . -name SKILL.md -print0 <span class="p">|</span> <span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> -r -d <span class="s1">&#39;&#39;</span> sk<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">        <span class="nv">d</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$sk</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        mkdir -p <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/skills/</span><span class="nv">$d</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        cp -a <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/skills/</span><span class="nv">$d</span><span class="s2">/.&#34;</span> <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/skills/</span><span class="nv">$d</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="k">done</span> <span class="o">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    收集到 </span><span class="k">$(</span>find <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/skills&#34;</span> -name SKILL.md 2&gt;/dev/null <span class="p">|</span> wc -l<span class="k">)</span><span class="s2"> 个 skill&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 平铺事故(2026-08): SKILL.md 直接落在 skills/ 下会造成同名重复</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> -e <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/skills/SKILL.md&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    !! 本机 skills/ 根目录存在 SKILL.md(平铺残留), 会同步成重复技能, 请先清理&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 收集记忆 (MEMORY.md + USER.md) ...&#34;</span>
</span></span><span class="line"><span class="cl">  rm -rf <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/memories&#34;</span>
</span></span><span class="line"><span class="cl">  mkdir -p <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/memories&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> m
</span></span><span class="line"><span class="cl">  <span class="k">for</span> m in MEMORY.md USER.md<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -f <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/memories/</span><span class="nv">$m</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      cp <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/memories/</span><span class="nv">$m</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/memories/</span><span class="nv">$m</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> -d <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;==&gt; 收集 extra profile: </span><span class="nv">$EXTRA_PROFILE</span><span class="s2"> ...&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">prep</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/profile-</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    rm -rf <span class="s2">&#34;</span><span class="nv">$prep</span><span class="s2">&#34;</span><span class="p">;</span> mkdir -p <span class="s2">&#34;</span><span class="nv">$prep</span><span class="s2">/skills&#34;</span> <span class="s2">&#34;</span><span class="nv">$prep</span><span class="s2">/memories&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -d <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/skills&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      <span class="o">(</span> <span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/skills&#34;</span> <span class="o">&amp;&amp;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        find . -name SKILL.md -print0 <span class="p">|</span> <span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> -r -d <span class="s1">&#39;&#39;</span> sk<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">          <span class="nv">d</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$sk</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">          mkdir -p <span class="s2">&#34;</span><span class="nv">$prep</span><span class="s2">/skills/</span><span class="nv">$d</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">          cp -a <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/skills/</span><span class="nv">$d</span><span class="s2">/.&#34;</span> <span class="s2">&#34;</span><span class="nv">$prep</span><span class="s2">/skills/</span><span class="nv">$d</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">done</span> <span class="o">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> m in MEMORY.md USER.md<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="o">[</span> -f <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/memories/</span><span class="nv">$m</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        cp <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/memories/</span><span class="nv">$m</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$prep</span><span class="s2">/memories/</span><span class="nv">$m</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">done</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 打包 -&gt; </span><span class="nv">$TARBALL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  tar -C <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">&#34;</span> -czf <span class="s2">&#34;</span><span class="nv">$TARBALL</span><span class="s2">&#34;</span> .
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    产物大小: </span><span class="k">$(</span>du -h <span class="s2">&#34;</span><span class="nv">$TARBALL</span><span class="s2">&#34;</span> <span class="p">|</span> cut -f1<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 推送 (本地 -&gt; VPS) ----------</span>
</span></span><span class="line"><span class="cl">push_dir<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">src</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="nv">dst</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$2</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -d <span class="s2">&#34;</span><span class="nv">$src</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;    (跳过: 本地无 </span><span class="nv">$src</span><span class="s2">)&#34;</span><span class="p">;</span> <span class="k">return</span> 0<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 推送 </span><span class="nv">$1</span><span class="s2"> -&gt; </span><span class="nv">$dst</span><span class="s2"> (最新者胜) ...&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;rm -rf &#39;</span><span class="nv">$REMOTE_STAGE</span><span class="s2">&#39;; mkdir -p &#39;</span><span class="nv">$REMOTE_STAGE</span><span class="s2">&#39; &#39;</span><span class="nv">$dst</span><span class="s2">&#39;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">(</span> <span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$src</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> tar czf - <span class="s2">&#34;</span><span class="si">${</span><span class="nv">EXCLUDES</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> . <span class="o">)</span> <span class="p">|</span> ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;tar xzf - -C &#39;</span><span class="nv">$REMOTE_STAGE</span><span class="s2">&#39;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 比较必须发生在目标端：只有那里同时看得见两边的 mtime</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> res <span class="nv">rc</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="nv">res</span><span class="o">=</span><span class="k">$(</span> <span class="o">(</span> cat <span class="s2">&#34;</span><span class="nv">$APPLIER</span><span class="s2">&#34;</span> <span class="k">)</span> <span class="p">|</span> ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      <span class="s2">&#34;cat &gt; </span><span class="nv">$REMOTE_MERGEPY</span><span class="s2">.nw &amp;&amp; </span><span class="nv">$REMOTE_PY</span><span class="s2"> </span><span class="nv">$REMOTE_MERGEPY</span><span class="s2">.nw &#39;</span><span class="nv">$REMOTE_STAGE</span><span class="s2">&#39; &#39;</span><span class="nv">$dst</span><span class="s2">&#39; &#39;</span><span class="nv">$TARGET_UID</span><span class="s2">:</span><span class="nv">$TARGET_GID</span><span class="s2">&#39;; rc=\$?; rm -rf &#39;</span><span class="nv">$REMOTE_STAGE</span><span class="s2">&#39; </span><span class="nv">$REMOTE_MERGEPY</span><span class="s2">.nw; exit \$rc&#34;</span> <span class="o">)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">||</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    覆盖/跳过/冲突 = </span><span class="nv">$res</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="m">0</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    !! 远端比较器返回 </span><span class="nv">$rc</span><span class="s2"> —— 有文件没写成功或回读校验不一致(见上方 stderr), 本次同步不可信&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 记忆合并（MEMORY.md / USER.md 共用, 一律文件参数） ----------</span>
</span></span><span class="line"><span class="cl">merge_md_remote<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 用法: merge_md_remote &lt;本地源 .md&gt; &lt;远端目标 .md&gt; &lt;本机预算字符数(仅用于告警)&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">src</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="nv">dst</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$2</span><span class="s2">&#34;</span> <span class="nv">budget</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$3</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -f <span class="s2">&#34;</span><span class="nv">$src</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;    (本地无 </span><span class="nv">$src</span><span class="s2">, 跳过)&#34;</span><span class="p">;</span> <span class="k">return</span> 0<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 汇聚端预算: MEMORY/USER 各自可配, 默认与实例同值(中枢兼作精修基准集, 保持可写)</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">hub_budget</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HUB_MEMORY_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">case</span> <span class="s2">&#34;</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$dst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> in USER.md<span class="o">)</span> <span class="nv">hub_budget</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$HUB_USER_BUDGET</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl">  ensure_helpers
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;mkdir -p &#39;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$dst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#39;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 推送合并 </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$dst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> -&gt; </span><span class="nv">$dst</span><span class="s2"> (基于 § 段落并集, 只补缺失) ...&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;cat &gt; &#39;</span><span class="nv">$REMOTE_MEMSRC</span><span class="s2">&#39;&#34;</span> &lt; <span class="s2">&#34;</span><span class="nv">$src</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;cat &gt; </span><span class="nv">$REMOTE_MERGEPY</span><span class="s2">&#34;</span> &lt; <span class="s2">&#34;</span><span class="nv">$MERGEPY</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="nv">$REMOTE_PY</span><span class="s2"> </span><span class="nv">$REMOTE_MERGEPY</span><span class="s2"> &#39;</span><span class="nv">$dst</span><span class="s2">&#39; &#39;</span><span class="nv">$REMOTE_MEMSRC</span><span class="s2">&#39; &#39;</span><span class="nv">$hub_budget</span><span class="s2">&#39;; rc=\$?; rm -f </span><span class="nv">$REMOTE_MERGEPY</span><span class="s2"> &#39;</span><span class="nv">$REMOTE_MEMSRC</span><span class="s2">&#39;; exit \$rc&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;chown </span><span class="nv">$TARGET_UID</span><span class="s2">:</span><span class="nv">$TARGET_GID</span><span class="s2"> &#39;</span><span class="nv">$dst</span><span class="s2">&#39; 2&gt;/dev/null || true&#34;</span>
</span></span><span class="line"><span class="cl">  check_budget <span class="s2">&#34;远端 </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$dst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span>remote_chars <span class="s2">&#34;</span><span class="nv">$dst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$budget</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">merge_md_local<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 用法: merge_md_local &lt;远端源 .md&gt; &lt;本地目标 .md&gt; &lt;预算字符数&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">rsrc</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="nv">ldst</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$2</span><span class="s2">&#34;</span> <span class="nv">budget</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$3</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  ensure_helpers
</span></span><span class="line"><span class="cl">  mkdir -p <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$ldst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">tmp</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/remote-</span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$ldst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 拉取并合并 </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$ldst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> -&gt; </span><span class="nv">$ldst</span><span class="s2"> (基于 § 段落并集, 保留本地独有) ...&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ! ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;cat &#39;</span><span class="nv">$rsrc</span><span class="s2">&#39; 2&gt;/dev/null&#34;</span> &gt; <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    (远端无 </span><span class="nv">$rsrc</span><span class="s2">, 跳过)&#34;</span><span class="p">;</span> <span class="k">return</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> ! -s <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    (远端 </span><span class="nv">$rsrc</span><span class="s2"> 为空, 跳过)&#34;</span><span class="p">;</span> rm -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">return</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;</span><span class="nv">$PYTHON</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$MERGEPY_WIN</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$ldst</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$ldst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$budget</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  rm -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  check_budget <span class="s2">&#34;本地 </span><span class="k">$(</span>basename <span class="s2">&#34;</span><span class="nv">$ldst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span>local_chars <span class="s2">&#34;</span><span class="nv">$ldst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$budget</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 拉取 (VPS -&gt; 本地) ----------</span>
</span></span><span class="line"><span class="cl">pull_skills<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">remote_src</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/skills&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">local_dst</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/skills&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 从 VPS 拉取 skills -&gt; </span><span class="nv">$local_dst</span><span class="s2"> (最新者胜) ...&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">stage</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/pull-skills&#34;</span>
</span></span><span class="line"><span class="cl">  rm -rf <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span><span class="p">;</span> mkdir -p <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$local_dst</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;tar </span><span class="si">${</span><span class="nv">EXCLUDES</span><span class="p">[*]</span><span class="si">}</span><span class="s2"> -czf - -C &#39;</span><span class="nv">$remote_src</span><span class="s2">&#39; .&#34;</span> <span class="p">|</span> tar xzf - -C <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 两个路径参数都必须转成 Windows 形式: 原生 python 打不开 MSYS 的 /c/...</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># (否则 os.path.isdir(staged) 为假 -&gt; 静默&#34;0 0 0&#34; 什么也不做)</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> staged_n res <span class="nv">rc</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="nv">staged_n</span><span class="o">=</span><span class="k">$(</span>find <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span> -type f 2&gt;/dev/null <span class="p">|</span> wc -l<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$staged_n</span><span class="s2">&#34;</span> -gt <span class="m">0</span> <span class="o">]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;    !! 暂存区为空: 拉取没拿到任何文件, 请检查远端路径/权限&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="k">return</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="nv">res</span><span class="o">=</span><span class="k">$(</span><span class="s2">&#34;</span><span class="nv">$PYTHON</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$APPLIER_WIN</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$local_dst</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span><span class="k">)</span> <span class="o">||</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl">  rm -rf <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    覆盖/跳过/冲突 = </span><span class="nv">$res</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="m">0</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    !! 比较器返回 </span><span class="nv">$rc</span><span class="s2"> —— 有文件没写成功或回读校验不一致, 本次同步不可信&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">pull_extra_profile<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="k">return</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">remote_base</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">local_base</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">stage</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/pull-profile-</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># 远端没有这个 profile 时直接跳过: 不要让空 tar 流把整次拉取打断</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;test -d &#39;</span><span class="nv">$remote_base</span><span class="s2">/skills&#39;&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;==&gt; 拉取 extra profile (</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">) skills -&gt; </span><span class="nv">$local_base</span><span class="s2">/skills (最新者胜) ...&#34;</span>
</span></span><span class="line"><span class="cl">    rm -rf <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span><span class="p">;</span> mkdir -p <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$local_base</span><span class="s2">/skills&#34;</span>
</span></span><span class="line"><span class="cl">    ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;tar </span><span class="si">${</span><span class="nv">EXCLUDES</span><span class="p">[*]</span><span class="si">}</span><span class="s2"> -czf - -C &#39;</span><span class="nv">$remote_base</span><span class="s2">/skills&#39; .&#34;</span> <span class="p">|</span> tar xzf - -C <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">local</span> <span class="nv">rc</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;</span><span class="nv">$PYTHON</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$APPLIER_WIN</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span>cygpath -w <span class="s2">&#34;</span><span class="nv">$local_base</span><span class="s2">/skills&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl">    rm -rf <span class="s2">&#34;</span><span class="nv">$stage</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">0</span> <span class="o">]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;    !! profile 比较器返回 </span><span class="nv">$rc</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="k">return</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    (远端无 </span><span class="nv">$remote_base</span><span class="s2">/skills, 跳过 profile 技能)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  merge_md_local <span class="s2">&#34;</span><span class="nv">$remote_base</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$local_base</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$MEMORY_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  merge_md_local <span class="s2">&#34;</span><span class="nv">$remote_base</span><span class="s2">/memories/USER.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$local_base</span><span class="s2">/memories/USER.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$USER_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 只看差异(不改任何东西): 一端独有、另一端没有的技能 ----------</span>
</span></span><span class="line"><span class="cl">list_only_here<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">side</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span>                          <span class="c1"># local | remote</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> tmp_l tmp_r
</span></span><span class="line"><span class="cl">  <span class="nv">tmp_l</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/.cmp-local&#34;</span><span class="p">;</span> <span class="nv">tmp_r</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/.cmp-remote&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">(</span> <span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/skills&#34;</span> <span class="o">&amp;&amp;</span> find . -name SKILL.md <span class="p">|</span> <span class="nv">LC_ALL</span><span class="o">=</span>C sort <span class="o">)</span> &gt; <span class="s2">&#34;</span><span class="nv">$tmp_l</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> : &gt; <span class="s2">&#34;</span><span class="nv">$tmp_l</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;cd &#39;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/skills&#39; &amp;&amp; find . -name SKILL.md | LC_ALL=C sort&#34;</span> &gt; <span class="s2">&#34;</span><span class="nv">$tmp_r</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> : &gt; <span class="s2">&#34;</span><span class="nv">$tmp_r</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 技能差异(仅报告, 不删任何东西)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    本机 </span><span class="k">$(</span>wc -l &lt; <span class="s2">&#34;</span><span class="nv">$tmp_l</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> 个 / 远端 </span><span class="k">$(</span>wc -l &lt; <span class="s2">&#34;</span><span class="nv">$tmp_r</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> 个&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    --- 只在本机有 ---&#34;</span><span class="p">;</span> <span class="nv">LC_ALL</span><span class="o">=</span>C comm -23 <span class="s2">&#34;</span><span class="nv">$tmp_l</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$tmp_r</span><span class="s2">&#34;</span> <span class="p">|</span> sed <span class="s1">&#39;s/^/      /&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    --- 只在远端有 ---&#34;</span><span class="p">;</span> <span class="nv">LC_ALL</span><span class="o">=</span>C comm -13 <span class="s2">&#34;</span><span class="nv">$tmp_l</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$tmp_r</span><span class="s2">&#34;</span> <span class="p">|</span> sed <span class="s1">&#39;s/^/      /&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    说明: 本工具是叠加语义, 这些差异不会被自动删除; 确认要清理时手工删对应端。&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$side</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="s2">&#34;local&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 动作 ----------</span>
</span></span><span class="line"><span class="cl">do_push<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  ensure_helpers
</span></span><span class="line"><span class="cl">  collect_local
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 推送 skills 到 VPS (最新者胜覆盖同名, 保留远端独有) ...&#34;</span>
</span></span><span class="line"><span class="cl">  push_dir <span class="s2">&#34;skills&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/skills&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 推送记忆到 VPS (追加合并, 不覆盖远端) ...&#34;</span>
</span></span><span class="line"><span class="cl">  merge_md_remote <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$MEMORY_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  merge_md_remote <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/memories/USER.md&#34;</span>   <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/memories/USER.md&#34;</span>   <span class="s2">&#34;</span><span class="nv">$USER_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> -d <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/profile-</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;==&gt; 推送 </span><span class="nv">$EXTRA_PROFILE</span><span class="s2"> profile 到 VPS ...&#34;</span>
</span></span><span class="line"><span class="cl">    push_dir <span class="s2">&#34;profile-</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/skills&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/skills&#34;</span>
</span></span><span class="line"><span class="cl">    merge_md_remote <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/profile-</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">                    <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$MEMORY_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    merge_md_remote <span class="s2">&#34;</span><span class="nv">$RUNDIR</span><span class="s2">/profile-</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/memories/USER.md&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">                    <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/profiles/</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">/memories/USER.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$USER_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># 收尾: 把集中存放资产的目录整棵 chown 回目标属主, 顺带清掉历史遗留的合成 uid</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 修正 VPS 侧属主 -&gt; </span><span class="nv">$TARGET_UID</span><span class="s2">:</span><span class="nv">$TARGET_GID</span><span class="s2"> (含历史遗留) ...&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SSH_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$VPS_USER_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;for d in &#39;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/skills&#39; &#39;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/memories&#39; &#39;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/profiles&#39;; do [ -e \&#34;\$d\&#34; ] &amp;&amp; chown -R </span><span class="nv">$TARGET_UID</span><span class="s2">:</span><span class="nv">$TARGET_GID</span><span class="s2"> \&#34;\$d\&#34;; done; ls -ld &#39;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/skills&#39;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 推送完成。如需使 VPS 生效，请在 VPS 上重启 hermes/gateway 容器。&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">do_pull<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  ensure_helpers
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 开始从 VPS (</span><span class="nv">$VPS_USER_HOST</span><span class="s2">) 拉取资产到本地 (</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">) ...&#34;</span>
</span></span><span class="line"><span class="cl">  pull_skills
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  merge_md_local <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/memories/MEMORY.md&#34;</span> <span class="s2">&#34;</span><span class="nv">$MEMORY_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  merge_md_local <span class="s2">&#34;</span><span class="nv">$VPS_HERMES_HOME</span><span class="s2">/memories/USER.md&#34;</span>   <span class="s2">&#34;</span><span class="nv">$LOCAL_HERMES_HOME</span><span class="s2">/memories/USER.md&#34;</span>   <span class="s2">&#34;</span><span class="nv">$USER_BUDGET</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$EXTRA_PROFILE</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    pull_extra_profile
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;==&gt; 拉取合并完成。本地 Hermes 已拥有 VPS 汇聚的完整技能与记忆。&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;    提醒: 同步是叠加语义, 远端删掉的技能不会在本机消失(需要时用 --list-only-here 看差异)。&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---------- 入口 ----------</span>
</span></span><span class="line"><span class="cl"><span class="nv">MODE</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$MODE</span><span class="s2">&#34;</span> in
</span></span><span class="line"><span class="cl">  --push<span class="o">)</span>
</span></span><span class="line"><span class="cl">    do_push
</span></span><span class="line"><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  --pull<span class="o">)</span>
</span></span><span class="line"><span class="cl">    do_pull
</span></span><span class="line"><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  --sync<span class="o">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;==&gt; 双向同步: 先 PULL 收敛其余端的增量, 再 PUSH 回传本端新增&#34;</span>
</span></span><span class="line"><span class="cl">    do_pull
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    do_push
</span></span><span class="line"><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  --list-only-here<span class="o">)</span>
</span></span><span class="line"><span class="cl">    ensure_helpers
</span></span><span class="line"><span class="cl">    list_only_here <span class="nb">local</span>
</span></span><span class="line"><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="cl">  *<span class="o">)</span>
</span></span><span class="line"><span class="cl">    ensure_helpers
</span></span><span class="line"><span class="cl">    collect_local
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;==&gt; 打包完成。&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    - 推送到 VPS: </span><span class="nv">$0</span><span class="s2"> --push&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    - 从 VPS 拉取: </span><span class="nv">$0</span><span class="s2"> --pull&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    - 双向同步:   </span><span class="nv">$0</span><span class="s2"> --sync&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;    - 只看技能差异(不删): </span><span class="nv">$0</span><span class="s2"> --list-only-here&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">;;</span>
</span></span><span class="line"><span class="cl"><span class="k">esac</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-94363ab409e25653e55ef8e4590d05f9 > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-94363ab409e25653e55ef8e4590d05f9 > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-94363ab409e25653e55ef8e4590d05f9 details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-94363ab409e25653e55ef8e4590d05f9 details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-94363ab409e25653e55ef8e4590d05f9");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


]]></content:encoded>
    </item>
    <item>
      <title>两台 VPS 每天备份到一台廉价存储箱：restic 的空间账、恢复演练与异常巡检</title>
      <link>https://slop.lun.sh/restic-backup-to-box/</link>
      <pubDate>Sat, 19 Sep 2026 00:35:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/restic-backup-to-box/</guid>
      <category>restic</category>
      <category>备份</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>两台 VPS 上的服务越来越多，却没有一条真正被验证过的备份链路。这篇记录最终落地的方案：用 restic 把两台机器每天增量备份到一台租来的 500 GB 存储箱（只能走 SFTP），保留 30 日 + 12 周 + 12 月，配恢复演练和&quot;正常静默、异常才响&quot;的巡检。适用环境：两台 Linux VPS（一台跑 Docker 服务栈，一台跑 LXD + 控制面板）、一台 Debian 13 存储箱（jailshell 受限账号）、restic 0.19.1，2026-09-11 部署并逐项实测。结论先说：<strong>这套东西的成本几乎只和&quot;每天的变化量&quot;挂钩</strong>，所以&quot;保留多久&quot;这个问题答案永远是&quot;比你以为的长&quot;。</p>
</blockquote>
<h2 class="relative group">一、方案取舍：为什么是 restic + SFTP 后端
    <div id="一方案取舍为什么是-restic--sftp-后端" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e6%96%b9%e6%a1%88%e5%8f%96%e8%88%8d%e4%b8%ba%e4%bb%80%e4%b9%88%e6%98%af-restic--sftp-%e5%90%8e%e7%ab%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>三件事按重要性排序：</p>
<ol>
<li><strong>客户端加密</strong>。数据在本地加密后才上传，存储箱供应商拿不到任何明文。代价必须一起说清楚：<strong>仓库口令丢失 = 数据永远不可恢复</strong>，供应商也救不了你。所以口令要存两处（本机受限权限文件 + 密码管理器），并且校验两处是否一致。</li>
<li><strong>去重 + 压缩</strong>。两台机器首份全量合计 6.98 GB 的扫描量，落到箱子上的仓库实际占用 4.31 GB（省约 38%）；之后每天只上传变化的数据块。</li>
<li><strong>后端足够傻</strong>。箱子只需要开 SFTP、给一个受限 shell 账号，不需要在服务商侧装任何东西，也不依赖任何厂商专用接口。</li>
</ol>
<p>与之对照的是&quot;每天 rsync 一份完整快照&quot;：N 天的保留就是 N 份完整副本，没有跨版本去重、也没有客户端加密（除非再叠一层），保留窗口一拉长，成本线性上涨。这里不是给 rsync 判死刑——它更适合&quot;镜像到另一台机器&quot;这种场景；但&quot;每天一份、留几个月&quot;的需求，去重备份是更省的那条路。</p>

<h2 class="relative group">二、配置长什么样
    <div id="二配置长什么样" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e9%85%8d%e7%bd%ae%e9%95%bf%e4%bb%80%e4%b9%88%e6%a0%b7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>整套东西就是两份配置文件 + 两个脚本 + 四个 systemd 单元（备份与巡检各一 service 一 timer）。它们已经按本站的编写原则原样附在下面，只把箱地址、账号名、主机代号与具体服务目录换成了占位符，逻辑一字未改。</p>





<div
  id="accordion-a87ccb1df1654ad18e95e679920c2475"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>backup-to-box.sh —— 备份主脚本（204 行）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># backup-to-box.sh — daily restic snapshot of this host onto the 500 GB backup box.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   config : /etc/backup-to-box.conf      (per host: REPO, PATHS, KEEP_*, hooks)</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   excludes: /etc/backup-to-box.exclude</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   status  : /var/lib/backup-to-box/status.env  +  &lt;box&gt;:/home/&lt;备份用户&gt;/status/&lt;host&gt;.env</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   log     : /var/log/backup-to-box.log</span>
</span></span><span class="line"><span class="cl"><span class="c1">#</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Exit 0 = ok, 1 = failure（systemd 单元显示 failed，巡检脚本据此告警）</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Manual run:  BACKUP_CONF=/etc/backup-to-box.conf DRY_RUN=1 /usr/local/sbin/backup-to-box.sh</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -uo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># --- defaults that a per-host config may override --------------------------</span>
</span></span><span class="line"><span class="cl"><span class="nb">declare</span> -a <span class="nv">PATHS</span><span class="o">=()</span> <span class="nv">PG_CONTAINERS</span><span class="o">=()</span> <span class="nv">MYSQL_CONTAINERS</span><span class="o">=()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">CONF</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BACKUP_CONF</span><span class="k">:-</span><span class="p">/etc/backup-to-box.conf</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> -r <span class="s2">&#34;</span><span class="nv">$CONF</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;FATAL: </span><span class="nv">$CONF</span><span class="s2"> unreadable&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 2<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="c1"># shellcheck disable=SC1090</span>
</span></span><span class="line"><span class="cl"><span class="nb">source</span> <span class="s2">&#34;</span><span class="nv">$CONF</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">RESTIC</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">RESTIC</span><span class="k">:-</span><span class="p">/usr/local/bin/restic</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">REPO</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">REPO</span><span class="p">:?REPO missing in </span><span class="nv">$CONF</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">HOST_TAG</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">HOST_TAG</span><span class="k">:-$(</span>hostname -s<span class="k">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">BOX_HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_HOST</span><span class="k">:-</span><span class="p">&lt;存储箱别名&gt;</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">BOX_STATUS_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_STATUS_DIR</span><span class="k">:-</span><span class="p">/home/&lt;备份用户&gt;/status</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">BOX_REPO_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_REPO_DIR</span><span class="k">:-</span><span class="p">/home/&lt;备份用户&gt;/restic</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">RESTIC_PASSWORD_FILE</span><span class="k">:-</span><span class="p">/root/.restic-box-pass</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">RESTIC_CACHE_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">RESTIC_CACHE_DIR</span><span class="k">:-</span><span class="p">/var/cache/restic</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">RESTIC_PROGRESS_FPS</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STATE_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">STATE_DIR</span><span class="k">:-</span><span class="p">/var/lib/backup-to-box</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">DUMP_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$STATE_DIR</span><span class="s2">/dbdumps&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">LOG_FILE</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">LOG_FILE</span><span class="k">:-</span><span class="p">/var/log/backup-to-box.log</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">STATUS_ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$STATE_DIR</span><span class="s2">/status.env&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">EXCLUDE_FILE</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">EXCLUDE_FILE</span><span class="k">:-</span><span class="p">/etc/backup-to-box.exclude</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">KEEP_DAILY</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">KEEP_DAILY</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">KEEP_WEEKLY</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">KEEP_WEEKLY</span><span class="k">:-</span><span class="nv">4</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">KEEP_MONTHLY</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">KEEP_MONTHLY</span><span class="k">:-</span><span class="nv">6</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">KEEP_YEARLY</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">KEEP_YEARLY</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">CHECK_SUBSET</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">CHECK_SUBSET</span><span class="k">:-</span><span class="nv">2</span><span class="p">%</span><span class="si">}</span><span class="s2">&#34;</span>                <span class="c1"># weekly `restic check --read-data-subset`</span>
</span></span><span class="line"><span class="cl"><span class="nv">BOX_CAP_BYTES</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_CAP_BYTES</span><span class="k">:-</span><span class="nv">483183820800</span><span class="si">}</span><span class="s2">&#34;</span>    <span class="c1"># 450 GiB self-imposed cap on the 500 GB plan</span>
</span></span><span class="line"><span class="cl"><span class="nv">MIN_FREE_MB</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">MIN_FREE_MB</span><span class="k">:-</span><span class="nv">2048</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">DRY_RUN</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">DRY_RUN</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="si">${#</span><span class="nv">PATHS</span><span class="p">[@]</span><span class="si">}</span> -eq <span class="m">0</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nv">PATHS</span><span class="o">=(</span>/root<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$STATE_DIR</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$RESTIC_CACHE_DIR</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- single instance ------------------------------------------------------</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> 9&gt;<span class="s2">&#34;</span><span class="nv">$STATE_DIR</span><span class="s2">/run.lock&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! flock -n 9<span class="p">;</span> <span class="k">then</span> <span class="nb">echo</span> <span class="s2">&#34;backup-to-box: another run is in progress, exiting&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 0<span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- logging --------------------------------------------------------------</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -f <span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="k">$(</span>stat -c%s <span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> 0<span class="k">)</span><span class="s2">&#34;</span> -gt <span class="m">5242880</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  mv -f <span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">.1&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="nv">STARTED</span><span class="o">=</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="p">;</span> <span class="nv">T0</span><span class="o">=</span><span class="k">$(</span>date +%s<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">STATUS</span><span class="o">=</span>ok<span class="p">;</span> <span class="nb">declare</span> -a <span class="nv">WARN</span><span class="o">=()</span>
</span></span><span class="line"><span class="cl">say<span class="o">()</span>  <span class="o">{</span> <span class="nb">printf</span> <span class="s1">&#39;%s %s\n&#39;</span> <span class="s2">&#34;</span><span class="k">$(</span>date <span class="s1">&#39;+%F %T%z&#39;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$*</span><span class="s2">&#34;</span> <span class="p">|</span> tee -a <span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">warn<span class="o">()</span> <span class="o">{</span> <span class="nv">WARN</span><span class="o">+=(</span><span class="s2">&#34;</span><span class="nv">$*</span><span class="s2">&#34;</span><span class="o">)</span><span class="p">;</span> say <span class="s2">&#34;WARN: </span><span class="nv">$*</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">fail<span class="o">()</span> <span class="o">{</span> <span class="nv">STATUS</span><span class="o">=</span>fail<span class="p">;</span> say <span class="s2">&#34;FAIL: </span><span class="nv">$*</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- helpers --------------------------------------------------------------</span>
</span></span><span class="line"><span class="cl">dump_pg<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">c</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/pg-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.dump&#34;</span> <span class="nv">tmp</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/pg-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.dump.tmp&#34;</span>
</span></span><span class="line"><span class="cl">  rm -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ! docker <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span> sh -c <span class="s1">&#39;exec pg_dump -U &#34;${POSTGRES_USER:-postgres}&#34; -d &#34;${POSTGRES_DB:-postgres}&#34; -Fc&#39;</span> &gt;<span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span> 2&gt;<span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/pg-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.err&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    rm -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span><span class="p">;</span> warn <span class="s2">&#34;pg_dump </span><span class="nv">$c</span><span class="s2"> failed: </span><span class="k">$(</span>tail -1 <span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/pg-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.err&#34;</span> 2&gt;/dev/null<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">return</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  mv -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span><span class="p">;</span> say <span class="s2">&#34;  dump </span><span class="nv">$c</span><span class="s2"> -&gt; </span><span class="k">$(</span>du -h <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span> <span class="p">|</span> cut -f1<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">dump_mysql<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">c</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="nv">out</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/mysql-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.sql.gz&#34;</span> <span class="nv">tmp</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/mysql-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.sql.gz.tmp&#34;</span>
</span></span><span class="line"><span class="cl">  rm -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ! docker <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span> sh -c <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">        if command -v mariadb-dump &gt;/dev/null 2&gt;&amp;1; then D=mariadb-dump; else D=mysqldump; fi
</span></span></span><span class="line"><span class="cl"><span class="s1">        exec &#34;$D&#34; -uroot -p&#34;${MARIADB_ROOT_PASSWORD:-${MYSQL_ROOT_PASSWORD:-}}&#34; \
</span></span></span><span class="line"><span class="cl"><span class="s1">             --single-transaction --quick --skip-lock-tables \
</span></span></span><span class="line"><span class="cl"><span class="s1">             --all-databases --events --routines --triggers&#39;</span> 2&gt;<span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/mysql-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.err&#34;</span> <span class="p">|</span> gzip -1 &gt;<span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    rm -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span><span class="p">;</span> warn <span class="s2">&#34;mysqldump </span><span class="nv">$c</span><span class="s2"> failed: </span><span class="k">$(</span>tail -1 <span class="s2">&#34;</span><span class="nv">$DUMP_DIR</span><span class="s2">/mysql-</span><span class="si">${</span><span class="nv">1</span><span class="si">}</span><span class="s2">.err&#34;</span> 2&gt;/dev/null<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">return</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  mv -f <span class="s2">&#34;</span><span class="nv">$tmp</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span><span class="p">;</span> say <span class="s2">&#34;  dump </span><span class="nv">$c</span><span class="s2"> -&gt; </span><span class="k">$(</span>du -h <span class="s2">&#34;</span><span class="nv">$out</span><span class="s2">&#34;</span> <span class="p">|</span> cut -f1<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">on_exit<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="nb">declare</span> -F post_backup &gt;/dev/null<span class="p">;</span> <span class="k">then</span> post_backup <span class="o">||</span> true<span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> <span class="nv">dur</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> T0 <span class="k">))</span>
</span></span><span class="line"><span class="cl">  say <span class="s2">&#34;===== backup-to-box </span><span class="nv">$HOST_TAG</span><span class="s2"> finished: STATUS=</span><span class="nv">$STATUS</span><span class="s2"> rc=</span><span class="nv">$rc</span><span class="s2"> duration=</span><span class="si">${</span><span class="nv">dur</span><span class="si">}</span><span class="s2">s =====&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$rc</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> on_exit EXIT
</span></span><span class="line"><span class="cl"><span class="c1"># make sure the cleanup hook runs even if the session is torn down mid-run</span>
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> <span class="s1">&#39;exit 129&#39;</span> HUP
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> <span class="s1">&#39;exit 143&#39;</span> TERM
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> <span class="s1">&#39;exit 130&#39;</span> INT
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">say <span class="s2">&#34;===== backup-to-box start host=</span><span class="nv">$HOST_TAG</span><span class="s2"> repo=</span><span class="nv">$REPO</span><span class="s2"> =====&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- pre ------------------------------------------------------------------</span>
</span></span><span class="line"><span class="cl"><span class="nv">avail_mb</span><span class="o">=</span><span class="k">$(</span>df -Pm <span class="s2">&#34;</span><span class="nv">$STATE_DIR</span><span class="s2">&#34;</span> <span class="p">|</span> awk <span class="s1">&#39;NR==2{print $4}&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">avail_mb</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span> -lt <span class="s2">&#34;</span><span class="nv">$MIN_FREE_MB</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> fail <span class="s2">&#34;only </span><span class="si">${</span><span class="nv">avail_mb</span><span class="si">}</span><span class="s2">MB free on </span><span class="nv">$STATE_DIR</span><span class="s2"> (need </span><span class="si">${</span><span class="nv">MIN_FREE_MB</span><span class="si">}</span><span class="s2">MB)&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="nb">declare</span> -F pre_backup &gt;/dev/null<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  say <span class="s2">&#34;--- pre_backup hook ---&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ! pre_backup<span class="p">;</span> <span class="k">then</span> fail <span class="s2">&#34;pre_backup hook failed — aborting before taking a snapshot&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">say <span class="s2">&#34;--- database dumps ---&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> c in <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PG_CONTAINERS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">do</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> dump_pg <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> c in <span class="s2">&#34;</span><span class="si">${</span><span class="nv">MYSQL_CONTAINERS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">do</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> dump_mysql <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- backup ---------------------------------------------------------------</span>
</span></span><span class="line"><span class="cl">say <span class="s2">&#34;--- restic backup (paths: </span><span class="si">${</span><span class="nv">PATHS</span><span class="p">[*]</span><span class="si">}</span><span class="s2">) ---&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">JSON</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$STATE_DIR</span><span class="s2">/last-backup.json&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">SNAP_ID</span><span class="o">=</span><span class="s2">&#34;&#34;</span><span class="p">;</span> <span class="nv">ADDED</span><span class="o">=</span>0<span class="p">;</span> <span class="nv">LOGICAL</span><span class="o">=</span>0<span class="p">;</span> <span class="nv">FILES</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$DRY_RUN</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;</span><span class="nv">$RESTIC</span><span class="s2">&#34;</span> -r <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> backup <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PATHS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> --exclude-file<span class="o">=</span><span class="s2">&#34;</span><span class="nv">$EXCLUDE_FILE</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     --host <span class="s2">&#34;</span><span class="nv">$HOST_TAG</span><span class="s2">&#34;</span> --tag daily --dry-run 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -20 <span class="p">|</span> tee -a <span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  rm -f <span class="s2">&#34;</span><span class="nv">$JSON</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;</span><span class="nv">$RESTIC</span><span class="s2">&#34;</span> -r <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> backup <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PATHS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      --exclude-file<span class="o">=</span><span class="s2">&#34;</span><span class="nv">$EXCLUDE_FILE</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">      --exclude-caches --exclude-if-present .nobackup <span class="se">\
</span></span></span><span class="line"><span class="cl">      --host <span class="s2">&#34;</span><span class="nv">$HOST_TAG</span><span class="s2">&#34;</span> --tag daily <span class="se">\
</span></span></span><span class="line"><span class="cl">      --json &gt;<span class="s2">&#34;</span><span class="nv">$JSON</span><span class="s2">&#34;</span> 2&gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">rc</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="nv">$rc</span> -ne <span class="m">0</span> <span class="o">&amp;&amp;</span> <span class="nv">$rc</span> -ne <span class="m">3</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span> fail <span class="s2">&#34;restic backup exited </span><span class="nv">$rc</span><span class="s2"> (see </span><span class="nv">$LOG_FILE</span><span class="s2">)&#34;</span><span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="nv">$rc</span> -eq <span class="m">3</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> warn <span class="s2">&#34;restic reported unreadable source files (exit 3)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">SNAP_ID</span><span class="o">=</span><span class="k">$(</span>jq -r <span class="s1">&#39;select(.message_type==&#34;summary&#34;)|.snapshot_id&#39;</span> <span class="s2">&#34;</span><span class="nv">$JSON</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="p">|</span> tail -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ADDED</span><span class="o">=</span><span class="k">$(</span>jq -r <span class="s1">&#39;select(.message_type==&#34;summary&#34;)|.data_added&#39;</span> <span class="s2">&#34;</span><span class="nv">$JSON</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="p">|</span> tail -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">LOGICAL</span><span class="o">=</span><span class="k">$(</span>jq -r <span class="s1">&#39;select(.message_type==&#34;summary&#34;)|.total_bytes_processed&#39;</span> <span class="s2">&#34;</span><span class="nv">$JSON</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="p">|</span> tail -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">FILES</span><span class="o">=</span><span class="k">$(</span>jq -r <span class="s1">&#39;select(.message_type==&#34;summary&#34;)|.total_files_processed&#39;</span> <span class="s2">&#34;</span><span class="nv">$JSON</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="p">|</span> tail -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> -z <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SNAP_ID</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">SNAP_ID</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;null&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> fail <span class="s2">&#34;no snapshot id in restic output&#34;</span>
</span></span><span class="line"><span class="cl">  say <span class="s2">&#34;  snapshot=</span><span class="si">${</span><span class="nv">SNAP_ID</span><span class="k">:-</span><span class="nv">none</span><span class="si">}</span><span class="s2"> files=</span><span class="si">${</span><span class="nv">FILES</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2"> scanned=</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="si">${</span><span class="nv">LOGICAL</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> new-data=</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="si">${</span><span class="nv">ADDED</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- retention ------------------------------------------------------------</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Forget runs every day (rolling delete is immediate); the real `prune`</span>
</span></span><span class="line"><span class="cl"><span class="c1"># repack only runs on Sundays, because pack rewriting over SFTP is the</span>
</span></span><span class="line"><span class="cl"><span class="c1"># slowest step by far (measured 7.5 min) and the box has plenty of slack.</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$STATUS</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;ok&#34;</span> <span class="o">&amp;&amp;</span> <span class="s2">&#34;</span><span class="nv">$DRY_RUN</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>date +%u<span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;7&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">PRUNE_ARGS</span><span class="o">=(</span>--prune<span class="o">)</span><span class="p">;</span> <span class="nv">PRUNE_NOTE</span><span class="o">=</span><span class="s2">&#34;weekly prune (Sunday)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nv">PRUNE_ARGS</span><span class="o">=()</span><span class="p">;</span> <span class="nv">PRUNE_NOTE</span><span class="o">=</span><span class="s2">&#34;prune deferred to Sunday&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  say <span class="s2">&#34;--- retention: keep-daily=</span><span class="nv">$KEEP_DAILY</span><span class="s2"> keep-weekly=</span><span class="nv">$KEEP_WEEKLY</span><span class="s2"> keep-monthly=</span><span class="nv">$KEEP_MONTHLY</span><span class="s2"> keep-yearly=</span><span class="nv">$KEEP_YEARLY</span><span class="s2"> [</span><span class="nv">$PRUNE_NOTE</span><span class="s2">] ---&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">KEEP_ARGS</span><span class="o">=(</span>--keep-daily <span class="s2">&#34;</span><span class="nv">$KEEP_DAILY</span><span class="s2">&#34;</span> --keep-weekly <span class="s2">&#34;</span><span class="nv">$KEEP_WEEKLY</span><span class="s2">&#34;</span> --keep-monthly <span class="s2">&#34;</span><span class="nv">$KEEP_MONTHLY</span><span class="s2">&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$KEEP_YEARLY</span><span class="s2">&#34;</span> -gt <span class="m">0</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nv">KEEP_ARGS</span><span class="o">+=(</span>--keep-yearly <span class="s2">&#34;</span><span class="nv">$KEEP_YEARLY</span><span class="s2">&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">FORGET_OUT</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$STATE_DIR</span><span class="s2">/last-forget.txt&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ! <span class="s2">&#34;</span><span class="nv">$RESTIC</span><span class="s2">&#34;</span> -r <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> forget <span class="s2">&#34;</span><span class="si">${</span><span class="nv">KEEP_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">PRUNE_ARGS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> --host <span class="s2">&#34;</span><span class="nv">$HOST_TAG</span><span class="s2">&#34;</span> &gt;<span class="s2">&#34;</span><span class="nv">$FORGET_OUT</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span>1<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    warn <span class="s2">&#34;restic forget/prune failed (see </span><span class="nv">$FORGET_OUT</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nv">SUMMARY</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;snapshots have been removed|no snapshots were removed&#39;</span> <span class="s2">&#34;</span><span class="nv">$FORGET_OUT</span><span class="s2">&#34;</span> <span class="p">|</span> tail -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">[[</span> -z <span class="s2">&#34;</span><span class="nv">$SUMMARY</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nv">SUMMARY</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;^keep [0-9]+ snapshots&#39;</span> <span class="s2">&#34;</span><span class="nv">$FORGET_OUT</span><span class="s2">&#34;</span> <span class="p">|</span> tail -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">    say <span class="s2">&#34;  </span><span class="si">${</span><span class="nv">SUMMARY</span><span class="k">:-</span><span class="nv">forget</span><span class="p"> finished</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  cat <span class="s2">&#34;</span><span class="nv">$FORGET_OUT</span><span class="s2">&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- weekly integrity check ----------------------------------------------</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$STATUS</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;ok&#34;</span> <span class="o">&amp;&amp;</span> <span class="s2">&#34;</span><span class="nv">$DRY_RUN</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;1&#34;</span> <span class="o">&amp;&amp;</span> <span class="s2">&#34;</span><span class="k">$(</span>date +%u<span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;7&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  say <span class="s2">&#34;--- weekly restic check (--read-data-subset=</span><span class="nv">$CHECK_SUBSET</span><span class="s2">) ---&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;</span><span class="nv">$RESTIC</span><span class="s2">&#34;</span> -r <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> check --read-data-subset<span class="o">=</span><span class="s2">&#34;</span><span class="nv">$CHECK_SUBSET</span><span class="s2">&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> warn <span class="s2">&#34;restic check reported problems&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- stats ----------------------------------------------------------------</span>
</span></span><span class="line"><span class="cl"><span class="nv">SNAPSHOTS</span><span class="o">=</span>0<span class="p">;</span> <span class="nv">REPO_RAW</span><span class="o">=</span>0<span class="p">;</span> <span class="nv">BOX_BYTES</span><span class="o">=</span>0<span class="p">;</span> <span class="nv">BOX_TOTAL</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$DRY_RUN</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nv">SNAPSHOTS</span><span class="o">=</span><span class="k">$(</span><span class="s2">&#34;</span><span class="nv">$RESTIC</span><span class="s2">&#34;</span> -r <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> snapshots --json 2&gt;/dev/null <span class="p">|</span> jq <span class="s1">&#39;length&#39;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> 0<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">REPO_RAW</span><span class="o">=</span><span class="k">$(</span><span class="s2">&#34;</span><span class="nv">$RESTIC</span><span class="s2">&#34;</span> -r <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> stats --json 2&gt;/dev/null <span class="p">|</span> jq -r <span class="s1">&#39;.total_size&#39;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> 0<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">BOX_BYTES</span><span class="o">=</span><span class="k">$(</span>ssh -o <span class="nv">BatchMode</span><span class="o">=</span>yes -o <span class="nv">ConnectTimeout</span><span class="o">=</span><span class="m">25</span> <span class="s2">&#34;</span><span class="nv">$BOX_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;du -sb </span><span class="nv">$BOX_REPO_DIR</span><span class="s2">/</span><span class="nv">$HOST_TAG</span><span class="s2"> 2&gt;/dev/null | cut -f1&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> 0<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">BOX_TOTAL</span><span class="o">=</span><span class="k">$(</span>ssh -o <span class="nv">BatchMode</span><span class="o">=</span>yes -o <span class="nv">ConnectTimeout</span><span class="o">=</span><span class="m">25</span> <span class="s2">&#34;</span><span class="nv">$BOX_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;du -sb </span><span class="nv">$BOX_REPO_DIR</span><span class="s2"> 2&gt;/dev/null | cut -f1&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> 0<span class="k">)</span>
</span></span><span class="line"><span class="cl">  say <span class="s2">&#34;  snapshots=</span><span class="nv">$SNAPSHOTS</span><span class="s2"> repo-raw-data=</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="si">${</span><span class="nv">REPO_RAW</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> box-repo=</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_BYTES</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> box-total=</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_TOTAL</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_TOTAL</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span> -gt <span class="s2">&#34;</span><span class="nv">$BOX_CAP_BYTES</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    warn <span class="s2">&#34;backup box usage </span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_TOTAL</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> exceeds the self-imposed cap </span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="nv">$BOX_CAP_BYTES</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> — shorten retention&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ---- status file (local + on the box) ------------------------------------</span>
</span></span><span class="line"><span class="cl">cat &gt; <span class="s2">&#34;</span><span class="nv">$STATUS_ENV</span><span class="s2">&#34;</span> <span class="s">&lt;&lt;EOF
</span></span></span><span class="line"><span class="cl"><span class="s">HOST=$HOST_TAG
</span></span></span><span class="line"><span class="cl"><span class="s">STATUS=$STATUS
</span></span></span><span class="line"><span class="cl"><span class="s">STARTED=$STARTED
</span></span></span><span class="line"><span class="cl"><span class="s">FINISHED=$(date -Is)
</span></span></span><span class="line"><span class="cl"><span class="s">SNAPSHOT=${SNAP_ID:-}
</span></span></span><span class="line"><span class="cl"><span class="s">FILES=${FILES:-0}
</span></span></span><span class="line"><span class="cl"><span class="s">SCANNED_BYTES=${LOGICAL:-0}
</span></span></span><span class="line"><span class="cl"><span class="s">ADDED_BYTES=${ADDED:-0}
</span></span></span><span class="line"><span class="cl"><span class="s">REPO_RAW_BYTES=${REPO_RAW:-0}
</span></span></span><span class="line"><span class="cl"><span class="s">BOX_BYTES=${BOX_BYTES:-0}
</span></span></span><span class="line"><span class="cl"><span class="s">BOX_TOTAL_BYTES=${BOX_TOTAL:-0}
</span></span></span><span class="line"><span class="cl"><span class="s">SNAPSHOTS=${SNAPSHOTS:-0}
</span></span></span><span class="line"><span class="cl"><span class="s">KEEP_DAILY=$KEEP_DAILY
</span></span></span><span class="line"><span class="cl"><span class="s">DURATION_S=$(( $(date +%s) - T0 ))
</span></span></span><span class="line"><span class="cl"><span class="s">WARN=${WARN[*]:-}
</span></span></span><span class="line"><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! ssh -o <span class="nv">BatchMode</span><span class="o">=</span>yes -o <span class="nv">ConnectTimeout</span><span class="o">=</span><span class="m">25</span> <span class="s2">&#34;</span><span class="nv">$BOX_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;cat &gt; </span><span class="nv">$BOX_STATUS_DIR</span><span class="s2">/</span><span class="nv">$HOST_TAG</span><span class="s2">.env&#34;</span> &lt;<span class="s2">&#34;</span><span class="nv">$STATUS_ENV</span><span class="s2">&#34;</span> 2&gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG_FILE</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  warn <span class="s2">&#34;could not publish status file to the backup box&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$STATUS</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;ok&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span> <span class="nb">exit</span> 0<span class="p">;</span> <span class="k">else</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="k">fi</span></span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>/etc/backup-to-box.conf —— 每台机器一份（28 行）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># /etc/backup-to-box.conf — &lt;本机代号&gt;
</span></span><span class="line"><span class="cl"># Daily restic snapshot to the 500 GB backup box.
</span></span><span class="line"><span class="cl"># 每台机器一份，改 REPO / HOST_TAG / PATHS / 容器名单即可
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">REPO=&#34;sftp://&lt;备份用户&gt;@&lt;存储箱地址&gt;:&lt;端口&gt;//home/&lt;备份用户&gt;/restic/&lt;主机代号&gt;&#34;
</span></span><span class="line"><span class="cl">HOST_TAG=&#34;&lt;主机代号&gt;&#34;
</span></span><span class="line"><span class="cl">BOX_HOST=&#34;&lt;存储箱别名&gt;&#34;
</span></span><span class="line"><span class="cl">EXCLUDE_FILE=&#34;/etc/backup-to-box.exclude&#34;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">PATHS=(
</span></span><span class="line"><span class="cl">  /root
</span></span><span class="line"><span class="cl">  /data
</span></span><span class="line"><span class="cl">  /etc
</span></span><span class="line"><span class="cl">  /usr/local
</span></span><span class="line"><span class="cl">  /var/spool/cron
</span></span><span class="line"><span class="cl">  /var/lib/docker/volumes
</span></span><span class="line"><span class="cl">  /var/lib/backup-to-box
</span></span><span class="line"><span class="cl">)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># databases dumped to /var/lib/backup-to-box/dbdumps before the snapshot
</span></span><span class="line"><span class="cl">PG_CONTAINERS=(&lt;pg容器1&gt; &lt;pg容器2&gt; &lt;pg容器3&gt; &lt;pg容器4&gt;)   # 换成你自己的 PostgreSQL 容器名
</span></span><span class="line"><span class="cl">MYSQL_CONTAINERS=(&lt;mysql容器1&gt; &lt;mysql容器2&gt; &lt;mysql容器3&gt;)  # docker ps 里列出来
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># retention（容量估算见文中“空间账”一节）
</span></span><span class="line"><span class="cl">KEEP_DAILY=30
</span></span><span class="line"><span class="cl">KEEP_WEEKLY=12
</span></span><span class="line"><span class="cl">KEEP_MONTHLY=12
</span></span><span class="line"><span class="cl">KEEP_YEARLY=0</span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>/etc/backup-to-box.exclude —— 排除清单（28 行）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># /etc/backup-to-box.exclude — &lt;主机代号&gt;  (restic exclude file: one pattern per line, # = comment)
</span></span><span class="line"><span class="cl"># Raw database data directories (their contents arrive as pg_dump/mysqldump files instead)
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;服务甲&gt;/db
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;服务乙&gt;/db
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;服务丙&gt;/db
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;反向代理&gt;/mysql
</span></span><span class="line"><span class="cl"># regenerable / bulk material
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;反向代理&gt;/data/logs
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;反向代理&gt;/letsencrypt-acme-challenge
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;在线文档&gt;/onlyoffice-dist
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;自建服务&gt;/data/bin
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;自建服务&gt;/data/lsp
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;自建服务&gt;/data/lazy-packages
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;自建服务&gt;/data/logs
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;自建服务&gt;/data/models_dev_cache.json
</span></span><span class="line"><span class="cl">/srv/docker-data/&lt;自建服务&gt;/data/models_dev_cache.json.corrupt
</span></span><span class="line"><span class="cl"># generic caches / logs
</span></span><span class="line"><span class="cl">.cache
</span></span><span class="line"><span class="cl">.npm
</span></span><span class="line"><span class="cl">node_modules
</span></span><span class="line"><span class="cl">*.log
</span></span><span class="line"><span class="cl">*.log.gz
</span></span><span class="line"><span class="cl">*.pyc
</span></span><span class="line"><span class="cl">__pycache__
</span></span><span class="line"><span class="cl">*.tmp
</span></span><span class="line"><span class="cl"># docker internals that must never travel
</span></span><span class="line"><span class="cl">/var/lib/docker/volumes/*/backingFsBlockDev
</span></span><span class="line"><span class="cl">/var/lib/docker/volumes/metadata.db</span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>backup-box-watch.sh —— 巡检脚本（68 行）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># backup-box-watch.sh — anomaly watchdog for the backup box.</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 只在一台机器上跑。 Reads the status files both hosts publish to the box and</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 只有异常才推送到通知通道。</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * backup reported a failure</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * last successful run older than MAX_AGE_H</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * repos together exceed the self-imposed cap (default 450 GiB of a 500 GB plan)</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -uo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">BOX_HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_HOST</span><span class="k">:-</span><span class="p">&lt;存储箱别名&gt;</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">STATUS_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_STATUS_DIR</span><span class="k">:-</span><span class="p">/home/&lt;备份用户&gt;/status</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">REPO_DIR</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">BOX_REPO_DIR</span><span class="k">:-</span><span class="p">/home/&lt;备份用户&gt;/restic</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">HOSTS</span><span class="o">=(</span><span class="si">${</span><span class="nv">WATCH_HOSTS</span><span class="k">:-</span><span class="p">&lt;主机甲&gt; &lt;主机乙&gt;</span><span class="si">}</span><span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">MAX_AGE_H</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">MAX_AGE_H</span><span class="k">:-</span><span class="nv">30</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">CAP_BYTES</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">CAP_BYTES</span><span class="k">:-</span><span class="nv">483183820800</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">TG</span><span class="o">=</span>/usr/local/sbin/notify.sh    <span class="c1"># 换成你自己的通知脚本（收一个参数：消息文本）</span>
</span></span><span class="line"><span class="cl"><span class="nv">LOG</span><span class="o">=</span>/var/log/backup-box-watch.log
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">ALERTS</span><span class="o">=()</span>
</span></span><span class="line"><span class="cl">log<span class="o">()</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">&#39;%s %s\n&#39;</span> <span class="s2">&#34;</span><span class="k">$(</span>date <span class="s1">&#39;+%F %T%z&#39;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$*</span><span class="s2">&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">total_used</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> h in <span class="s2">&#34;</span><span class="si">${</span><span class="nv">HOSTS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">txt</span><span class="o">=</span><span class="k">$(</span>ssh -o <span class="nv">BatchMode</span><span class="o">=</span>yes -o <span class="nv">ConnectTimeout</span><span class="o">=</span><span class="m">25</span> -o <span class="nv">StrictHostKeyChecking</span><span class="o">=</span>accept-new <span class="se">\
</span></span></span><span class="line"><span class="cl">            <span class="s2">&#34;</span><span class="nv">$BOX_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;cat </span><span class="nv">$STATUS_DIR</span><span class="s2">/</span><span class="nv">$h</span><span class="s2">.env 2&gt;/dev/null&#34;</span> 2&gt;/dev/null<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> -z <span class="s2">&#34;</span><span class="si">${</span><span class="nv">txt</span><span class="p">//[[:</span><span class="nv">space</span><span class="p">:]]/</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">ALERTS</span><span class="o">+=(</span><span class="s2">&#34;❌ </span><span class="nv">$h</span><span class="s2">：备份箱上没有状态文件（该机备份从未成功或状态目录被清）&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  get<span class="o">()</span> <span class="o">{</span> <span class="nb">printf</span> <span class="s1">&#39;%s\n&#39;</span> <span class="s2">&#34;</span><span class="nv">$txt</span><span class="s2">&#34;</span> <span class="p">|</span> sed -n <span class="s2">&#34;s/^</span><span class="nv">$1</span><span class="s2">=//p&#34;</span> <span class="p">|</span> head -1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="nv">st</span><span class="o">=</span><span class="k">$(</span>get STATUS<span class="k">)</span><span class="p">;</span> <span class="nv">fin</span><span class="o">=</span><span class="k">$(</span>get FINISHED<span class="k">)</span><span class="p">;</span> <span class="nv">snaps</span><span class="o">=</span><span class="k">$(</span>get SNAPSHOTS<span class="k">)</span><span class="p">;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="nv">box</span><span class="o">=</span><span class="k">$(</span>get BOX_BYTES<span class="k">)</span><span class="p">;</span> <span class="nv">warn</span><span class="o">=</span><span class="k">$(</span>get WARN<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$st</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;ok&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">ALERTS</span><span class="o">+=(</span><span class="s2">&#34;❌ </span><span class="nv">$h</span><span class="s2">：最近一次备份失败（STATUS=</span><span class="nv">$st</span><span class="s2">，WARN=</span><span class="si">${</span><span class="nv">warn</span><span class="k">:-</span><span class="nv">none</span><span class="si">}</span><span class="s2">）&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> -n <span class="s2">&#34;</span><span class="nv">$fin</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">ep</span><span class="o">=</span><span class="k">$(</span>date -d <span class="s2">&#34;</span><span class="nv">$fin</span><span class="s2">&#34;</span> +%s 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> 0<span class="k">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$ep</span><span class="s2">&#34;</span> -gt <span class="m">0</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">      <span class="nv">age_h</span><span class="o">=</span><span class="k">$((</span> <span class="o">(</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> ep <span class="o">)</span> <span class="o">/</span> <span class="m">3600</span> <span class="k">))</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$age_h</span><span class="s2">&#34;</span> -gt <span class="s2">&#34;</span><span class="nv">$MAX_AGE_H</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        <span class="nv">ALERTS</span><span class="o">+=(</span><span class="s2">&#34;❌ </span><span class="nv">$h</span><span class="s2">：已 </span><span class="si">${</span><span class="nv">age_h</span><span class="si">}</span><span class="s2">h 没有成功备份（最后成功 </span><span class="nv">$fin</span><span class="s2">）&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  log <span class="s2">&#34;host=</span><span class="nv">$h</span><span class="s2"> status=</span><span class="nv">$st</span><span class="s2"> finished=</span><span class="nv">$fin</span><span class="s2"> snapshots=</span><span class="nv">$snaps</span><span class="s2"> box_bytes=</span><span class="si">${</span><span class="nv">box</span><span class="k">:-</span><span class="p">?</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">used</span><span class="o">=</span><span class="k">$(</span>ssh -o <span class="nv">BatchMode</span><span class="o">=</span>yes -o <span class="nv">ConnectTimeout</span><span class="o">=</span><span class="m">25</span> <span class="s2">&#34;</span><span class="nv">$BOX_HOST</span><span class="s2">&#34;</span> <span class="s2">&#34;du -sb </span><span class="nv">$REPO_DIR</span><span class="s2"> 2&gt;/dev/null | cut -f1&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> -n <span class="s2">&#34;</span><span class="nv">$used</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nv">total_used</span><span class="o">=</span><span class="nv">$used</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$used</span><span class="s2">&#34;</span> -gt <span class="s2">&#34;</span><span class="nv">$CAP_BYTES</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">ALERTS</span><span class="o">+=(</span><span class="s2">&#34;⚠️ 备份箱占用 </span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="nv">$used</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2"> 已超过自设上限 </span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="nv">$CAP_BYTES</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">，请缩短保留策略&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  log <span class="s2">&#34;box total used=</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="nv">$used</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ALERTS</span><span class="o">+=(</span><span class="s2">&#34;❌ 无法连接备份箱 </span><span class="nv">$BOX_HOST</span><span class="s2">，无法核对备份状态&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[[</span> <span class="si">${#</span><span class="nv">ALERTS</span><span class="p">[@]</span><span class="si">}</span> -gt <span class="m">0</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nv">msg</span><span class="o">=</span><span class="s2">&#34;🗄 备份箱巡检异常 </span><span class="k">$(</span>date <span class="s1">&#39;+%F %H:%M %Z&#39;</span><span class="k">)</span><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s\n&#39;</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">ALERTS</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">当前占用：</span><span class="si">${</span><span class="nv">total_used</span><span class="p">:+</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="nv">$total_used</span><span class="s2">&#34;</span><span class="k">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="s2">&#34;</span><span class="nv">$TG</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$msg</span><span class="s2">&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="cl">  log <span class="s2">&#34;alert sent&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  log <span class="s2">&#34;all good (box used=</span><span class="k">$(</span>numfmt --to<span class="o">=</span>iec <span class="s2">&#34;</span><span class="si">${</span><span class="nv">total_used</span><span class="k">:-</span><span class="nv">0</span><span class="si">}</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-a87ccb1df1654ad18e95e679920c2475 > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-a87ccb1df1654ad18e95e679920c2475 > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-a87ccb1df1654ad18e95e679920c2475 details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-a87ccb1df1654ad18e95e679920c2475 details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-a87ccb1df1654ad18e95e679920c2475");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/backup-to-box.service</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Daily restic backup to the backup box</span>
</span></span><span class="line"><span class="cl"><span class="na">Documentation</span><span class="o">=</span><span class="s">file:/etc/backup-to-box.conf</span>
</span></span><span class="line"><span class="cl"><span class="na">Wants</span><span class="o">=</span><span class="s">network-online.target</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network-online.target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/sbin/backup-to-box.sh</span>
</span></span><span class="line"><span class="cl"><span class="na">TimeoutStartSec</span><span class="o">=</span><span class="s">6h</span>
</span></span><span class="line"><span class="cl"><span class="na">Nice</span><span class="o">=</span><span class="s">10</span>
</span></span><span class="line"><span class="cl"><span class="na">IOSchedulingClass</span><span class="o">=</span><span class="s">idle</span>
</span></span><span class="line"><span class="cl"><span class="na">CPUSchedulingPolicy</span><span class="o">=</span><span class="s">batch</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/backup-to-box.timer</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Daily restic backup to the backup box</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Timer]</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 两台机器错峰：另一台写 04:30，随机延迟 20 分钟避免同时压同一个箱子</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* 03:30:00</span>
</span></span><span class="line"><span class="cl"><span class="na">RandomizedDelaySec</span><span class="o">=</span><span class="s">20min</span>
</span></span><span class="line"><span class="cl"><span class="na">AccuracySec</span><span class="o">=</span><span class="s">1min</span>
</span></span><span class="line"><span class="cl"><span class="na">Persistent</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">timers.target</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/backup-box-watch.service（只在一台机器上启用）</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Anomaly watchdog for the backup box repos</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/local/sbin/backup-box-watch.sh</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/backup-box-watch.timer</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">Anomaly watchdog for the backup box repos</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Timer]</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* 10:00:00</span>
</span></span><span class="line"><span class="cl"><span class="na">RandomizedDelaySec</span><span class="o">=</span><span class="s">10min</span>
</span></span><span class="line"><span class="cl"><span class="na">AccuracySec</span><span class="o">=</span><span class="s">1min</span>
</span></span><span class="line"><span class="cl"><span class="na">Persistent</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">timers.target</span></span></span></code></pre></div></div>
<p>几个设计点值得单独说明：</p>
<ul>
<li><strong><code>Persistent=true</code></strong>：机器错过当天窗口（重启、断电）后，下次启动会补跑一次，而不是直接跳过一天。</li>
<li><strong><code>Nice</code>/<code>IOSchedulingClass</code>/<code>CPUSchedulingPolicy</code></strong>：备份是纯 IO + CPU 的后台活，降优先级避免和线上服务抢资源。</li>
<li><strong>单实例锁</strong>（脚本里的 <code>flock -n</code>）：手动跑和定时跑撞在一起时，后来者直接退出，不会两个 restic 同时操作同一个仓库。</li>
<li><strong>数据库先转储再备份</strong>：脚本会先在容器里跑 <code>pg_dump -Fc</code> / <code>mysqldump</code>，把转储文件落到状态目录，再连同主机文件一起快照——这是后面&quot;省钱第一刀&quot;的基础。</li>
</ul>

<h2 class="relative group">三、保留策略与空间账
    <div id="三保留策略与空间账" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e4%bf%9d%e7%95%99%e7%ad%96%e7%95%a5%e4%b8%8e%e7%a9%ba%e9%97%b4%e8%b4%a6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>保留策略：<code>--keep-daily 30 --keep-weekly 12 --keep-monthly 12</code>。下面是实测账（都是真跑出来的数字）：</p>
<table>
	<thead>
			<tr>
					<th>项</th>
					<th>一台（Docker 服务栈）</th>
					<th>另一台（LXD + 控制面板）</th>
					<th>合计</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>首份全量：扫描体积 → 箱上占用</td>
					<td>1.94 GB（9164 个文件）→ <strong>1.71 GB</strong></td>
					<td>5.04 GB（137440 个文件）→ <strong>2.60 GB</strong></td>
					<td>6.98 GB → <strong>4.31 GB</strong></td>
			</tr>
			<tr>
					<td>后续每次运行的新增数据</td>
					<td>128–373 MB</td>
					<td>9–10 MB</td>
					<td>约 0.3–0.5 GB/日</td>
			</tr>
	</tbody>
</table>
<p>按箱子上自设的 450 GiB 上限反推（脚本里 <code>BOX_CAP_BYTES</code> 超限会告警，运行日志里每次都打印当前占用）：</p>
<ul>
<li>以 0.5 GB/日计，<strong>能存约 900 个每日版本 ≈ 2.5 年</strong>；</li>
<li>就算完全不靠去重、每天存一份完整独立副本，也还有 <strong>约 112 份</strong>；</li>
<li>30 日 + 12 周 + 12 月这套策略目前只占箱子的 <strong>约 6%（约 31 GB）</strong>。</li>
</ul>
<p>所以&quot;保留 3–5 天够不够&quot;这个问题，答案是<strong>不够，而且没有任何理由这么短</strong>：日增量本来就小、预算远远用不完，而数据损坏、配置漂移这类问题往往要几天甚至几周才暴露——留 3–5 天等于把发现问题的窗口直接扔掉，换来的节省是零。当前实际占用（部署两天后）是 4.8 GB。</p>

<h2 class="relative group">四、<code>forget</code> 与 <code>prune</code> 分开跑
    <div id="四forget-与-prune-分开跑" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9bforget-%e4%b8%8e-prune-%e5%88%86%e5%bc%80%e8%b7%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>restic 的保留是两步：<code>forget</code> 只删快照引用（瞬时），<code>prune</code> 才真正重写数据包、回收磁盘。实测在 SFTP 后端上，<code>prune</code> 是最慢的一步：</p>
<table>
	<thead>
			<tr>
					<th>运行方式</th>
					<th>耗时</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>不带 prune（只 forget）</td>
					<td><strong>98 秒</strong></td>
			</tr>
			<tr>
					<td>带 prune</td>
					<td><strong>451 秒</strong>（约 7.5 分钟）</td>
			</tr>
			<tr>
					<td>另一台机器的日常（不带 prune）</td>
					<td>约 110 秒</td>
			</tr>
	</tbody>
</table>
<p>所以脚本里的策略是：<strong>每天跑 forget，只有周日才带 <code>--prune</code></strong>；同一天顺便跑一次 <code>restic check --read-data-subset=2%</code>（抽查 2% 的数据块做完整性校验）。副作用是周日之前 <code>du</code> 看到的占用会比实际需要多几天（约 3 GB），完全可以忽略。</p>

<h2 class="relative group">五、省空间的两刀
    <div id="五省空间的两刀" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%9c%81%e7%a9%ba%e9%97%b4%e7%9a%84%e4%b8%a4%e5%88%80" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>第一刀：把数据库的原始数据目录排除，改用逻辑转储。</strong> 一台机器上某个服务的 PostgreSQL 原始数据目录约 9.5 GB，直接备份它，首份全量会到 11 GB 量级；改成备份 <code>pg_dump -Fc</code> 出来的转储文件后，那个库只有 <strong>751 MB</strong>，这台机器的首份全量随之落到 1.94 GB。注意转储要在快照之前生成并落到被备份的路径里（脚本里是 <code>/var/lib/backup-to-box/dbdumps</code>），否则快照里只有转储的&quot;上一版&quot;。</p>
<p><strong>第二刀：排除清单。</strong> 分四类，判断标准只有一条——<strong>能不能重新得到</strong>：</p>
<table>
	<thead>
			<tr>
					<th>类别</th>
					<th>例</th>
					<th>判断</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>原始数据库目录</td>
					<td>各服务的 <code>db/</code>、<code>mysql/</code></td>
					<td>已有逻辑转储，原始目录排除（见上）</td>
			</tr>
			<tr>
					<td>可再生的大件</td>
					<td>反向代理的日志与 ACME 挑战目录、在线文档的静态发行目录（约 1.1 GB）</td>
					<td>重装/重下即得</td>
			</tr>
			<tr>
					<td>通用缓存与日志</td>
					<td><code>.cache</code>（约 1.1 GB）、<code>.npm</code>、<code>node_modules</code>、<code>*.log</code>、<code>__pycache__</code></td>
					<td>重生成即得</td>
			</tr>
			<tr>
					<td>容器内部件</td>
					<td>各容器内 <code>var/log</code>（697 MB）、<code>var/cache</code>（676 MB）、<code>doc</code>/<code>man</code>/<code>locale</code>；Docker 卷的 <code>backingFsBlockDev</code>、<code>metadata.db</code></td>
					<td>绝不该进备份（有些甚至不该被读）</td>
			</tr>
	</tbody>
</table>
<p>另外一台机器上的容器跑在 LXD + ZFS 上：备份前给容器打只读快照再挂载，所以<strong>跨天的增量几乎为 0</strong>（实测 9–10 MB/次）——文件内容没变，去重自然全命中。</p>

<h2 class="relative group">六、恢复演练：没演练过的备份不算备份
    <div id="六恢复演练没演练过的备份不算备份" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e6%81%a2%e5%a4%8d%e6%bc%94%e7%bb%83%e6%b2%a1%e6%bc%94%e7%bb%83%e8%bf%87%e7%9a%84%e5%a4%87%e4%bb%bd%e4%b8%8d%e7%ae%97%e5%a4%87%e4%bb%bd" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>部署完只做了三件事，缺一件这套东西就不成立：</p>
<ol>
<li><strong>逐字节比对</strong>：从快照里 restore 出 <code>/etc/hostname</code>、<code>/etc/ssh/sshd_config</code>，与线上文件 <code>diff</code> → 完全一致。</li>
<li><strong>恢复数据库转储</strong>：从快照里取出 <code>pg-*.dump</code>（4.85 MB）→ restore 成功（<code>Restored 5/1 files</code>），证明&quot;转储确实进了快照、也确实能取出来&quot;。</li>
<li><strong>仓库自检</strong>：两个仓库 <code>restic check</code> 均 <code>no errors were found</code>（一台 4.3 秒；另一台因为要多次往返 SFTP，元数据检查花了 6.5 分钟——慢是正常的，不是坏了）。</li>
</ol>
<p>演练的命令形态（示例）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">RESTIC_PASSWORD_FILE</span><span class="o">=</span>/root/.restic-box-pass
</span></span><span class="line"><span class="cl">restic -r <span class="s2">&#34;</span><span class="nv">$REPO</span><span class="s2">&#34;</span> restore latest --target /tmp/drill <span class="se">\
</span></span></span><span class="line"><span class="cl">  --include /etc/hostname --include /etc/ssh/sshd_config
</span></span><span class="line"><span class="cl">diff -s /tmp/drill/etc/hostname /etc/hostname</span></span></code></pre></div></div>

<h2 class="relative group">七、巡检：正常静默，异常才响
    <div id="七巡检正常静默异常才响" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%83%e5%b7%a1%e6%a3%80%e6%ad%a3%e5%b8%b8%e9%9d%99%e9%bb%98%e5%bc%82%e5%b8%b8%e6%89%8d%e5%93%8d" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>巡检脚本只在一台机器上跑（这也是它最大的弱点，见下），每天 10:00 左右执行，读两台机器发布到箱子上的 <code>status.env</code>，只在三种情况下推送通知：</p>
<table>
	<thead>
			<tr>
					<th>触发条件</th>
					<th>含义</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>STATUS != ok</code></td>
					<td>最近一次备份自报失败</td>
			</tr>
			<tr>
					<td>最后成功时间超过 30 小时</td>
					<td>备份&quot;没在跑&quot;或&quot;跑了没成&quot;，两种都算异常</td>
			</tr>
			<tr>
					<td>两个仓库合计占用超过自设上限（450 GiB / 500 GB 套餐）</td>
					<td>该缩短保留策略了</td>
			</tr>
	</tbody>
</table>
<p>正常时它只写一行日志（<code>all good</code>），不打扰任何人。告警链路的自测方式是：手动写一个 <code>STATUS=fail</code> 的假状态文件，用环境变量把巡检范围限定到它，跑一次——收到一条测试消息、日志出现 <code>alert sent</code>，再清理掉假文件。</p>

<h2 class="relative group">八、风险与教训
    <div id="八风险与教训" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ab%e9%a3%8e%e9%99%a9%e4%b8%8e%e6%95%99%e8%ae%ad" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>口令就是数据本体</strong>。仓库口令丢失 = 数据永不可恢复，官方 <code>restic init</code> 时就提示过。我们把它生成在机器上（48 字符随机串，不打印到终端）、<code>0600</code> 落文件、另存一份到密码管理器。</li>
<li><strong>比对哈希先统一口径</strong>：校验&quot;密码管理器里的口令和机器上那份是否一致&quot;时，两次都对不上——第一次是命令行环境没登录，读出来是空串；第二次是读出的值带了尾随换行。两次都属于&quot;口径没统一&quot;，不是口令真的不同。任何哈希比对都要先做同样的规范化处理。</li>
<li><strong>命令输出本身会泄密</strong>：排查一个容器时用 <code>docker inspect</code> 取值，结果把另一个服务的数据库口令打印进了日志。处置是轮换那两个库的口令；此后的规矩是——要取容器内的环境变量，一律 <code>docker exec &lt;容器&gt; sh -c '...'</code> 在容器内展开，绝不在宿主侧 inspect 出全部环境。</li>
<li><strong>别信箱子上的 <code>df</code></strong>：共享存储上 <code>df</code> 报的是整卷（几百 TB），套餐配额不在文件系统层体现。判断占用只认 <code>du -sb</code> 数出来的真实字节，这也是脚本里那个上限告警的由来。</li>
<li><strong>巡检依赖单机存活</strong>：巡检脚本自己跑在其中的一台机器上，那台机器整体挂掉时，巡检也停，箱子只会静默变旧。这条目前没闭环（要么给巡检加外部存活探测，要么让箱子侧自己也发心跳），先记下。</li>
</ul>

<h2 class="relative group">九、留给后来者的清单
    <div id="九留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b9%9d%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>先定&quot;容灾目标&quot;再选工具：要的是&quot;能回到任意一天&quot;，去重备份 + 长保留几乎不增加成本；只要&quot;镜像一份&quot;，rsync 更直接。</li>
<li>客户端加密的前提是口令双份保存 + 校验一致；口令丢了没有任何补救。</li>
<li>保留策略大胆拉长：30 日 + 12 周 + 12 月这套在 500 GB 级箱子上只占个位数百分比。</li>
<li><code>forget</code> 每天跑、<code>prune</code> 每周跑，SFTP 后端下差别是 98 秒对 451 秒。</li>
<li>数据库先逻辑转储再备份：9.5 GB 的原始目录能变成 751 MB 的转储。</li>
<li>排除清单按&quot;能不能重新得到&quot;来分：缓存、日志、可再生大件、容器内部件。</li>
<li>部署当天就做恢复演练：文件逐字节比对 + 从快照里取出并恢复一份数据库转储。</li>
<li>巡检正常静默、异常才响；把&quot;多久没成功&quot;也当成异常条件，而不只是看&quot;最近一次是否失败&quot;。</li>
<li>占用按 <code>du -sb</code> 算，别被共享存储的 <code>df</code> 骗；给仓库设一个自设上限并告警。</li>
<li>日志里出现过的任何口令，都当作已泄露处理。</li>
</ul>

<h2 class="relative group">十、碎碎念
    <div id="十碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8d%81%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>备份这件事的悖论是：它的价值只在最坏的那天兑现，所以平时最容易被砍掉、也最容易被&quot;看起来在跑&quot;糊弄。真正让这套方案落地的不是 restic 本身，而是三个附加动作——保留策略敢拉长、部署当天做恢复演练、巡检只在异常时响。前两个决定&quot;真出事时能不能救回来&quot;，最后一个决定&quot;这套东西还在不在工作&quot;。</p>
]]></content:encoded>
    </item>
    <item>
      <title>四则「看起来像模型坏了」的排障：预算、端点、内存态、身份</title>
      <link>https://slop.lun.sh/agent-troubleshooting-four/</link>
      <pubDate>Sat, 19 Sep 2026 00:30:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/agent-troubleshooting-four/</guid>
      <category>Hermes</category>
      <category>智能体</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>适用环境：Windows 11 上的 Hermes Agent（0.21.0）日常使用，另有一个承载定时任务的 Docker 容器（Linux）；时间 2026 年 9 月上旬。四则故障摆在桌面上时都像「模型坏了」——空响应、偶发 400、模型列表消失、定时任务报错——查到最后全部落在预算、端点、进程状态、身份权限四类非模型问题上。结论：症状出现在模型层，根因往往在模型层之外；先取系统事实（token 用量、请求落盘、PID、文件属主），再动手改。</p>
</blockquote>
<h2 class="relative group">为什么会连续遇到这一类问题
    <div id="为什么会连续遇到这一类问题" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e4%bc%9a%e8%bf%9e%e7%bb%ad%e9%81%87%e5%88%b0%e8%bf%99%e4%b8%80%e7%b1%bb%e9%97%ae%e9%a2%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>AI Agent 把「调用模型」变成了一次复杂编排：多模型并行、多通道可选、配置热改、进程常驻、定时任务换身份执行。任何一环与模型的接口假设不一致，故障都表现为「模型不干活」，因为诊断入口天然在模型层。四则案例时间紧挨着出现，故障面各不相同，但有一个共同点：<strong>第一现场的证据全部在模型层之外</strong>——token 用量、请求落盘、PID 与文件时间戳、文件属主与调度身份。把「是不是模型坏了」当成待验证的假设而非事实，先翻这四个事实源，大概率在第一页证据里就能定位。另一条主线是复现纪律：<strong>用与故障完全相同的代码路径、相同的身份、相同的通道去复现</strong>，一半的疑难在复现那一刻就结束了。速查表如下：</p>
<table>
	<thead>
			<tr>
					<th>症状</th>
					<th>真因</th>
					<th>一条命令验证</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>聚合/多槽输出全空，聚合器声明「参考响应均为空」</td>
					<td>参考槽思考强度与输出预算不匹配，思考 token 吃干预算</td>
					<td>查用量账：<code>reasoning_tokens</code> 接近 <code>completion_tokens</code></td>
			</tr>
			<tr>
					<td>推理模型 + 工具偶发 400</td>
					<td>请求走了 <code>/v1/chat/completions</code>，上游要求走 <code>/v1/responses</code></td>
					<td>翻请求落盘 JSON：<code>request.url</code> 是否指向 <code>/v1/responses</code></td>
			</tr>
			<tr>
					<td>改好的模型列表在桌面端看不到，新对话无法调用</td>
					<td>后端进程启动早于改动，模型目录在内存里是旧表</td>
					<td>比 PID 启动时间与文件改动时间</td>
			</tr>
			<tr>
					<td>定时任务写状态文件 <code>PermissionError</code>，手动跑却正常</td>
					<td>调度身份（uid 1000）与文件属主（root）不一致，且异常被静默吞掉</td>
					<td>用 <code>docker exec -u &lt;uid&gt;:&lt;gid&gt;</code> 复现</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">一、专家模型无响应：思考把输出预算吃干
    <div id="一专家模型无响应思考把输出预算吃干" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e4%b8%93%e5%ae%b6%e6%a8%a1%e5%9e%8b%e6%97%a0%e5%93%8d%e5%ba%94%e6%80%9d%e8%80%83%e6%8a%8a%e8%be%93%e5%87%ba%e9%a2%84%e7%ae%97%e5%90%83%e5%b9%b2" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">症状与第一手数据
    <div id="症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>多专家协作（多个参考模型并行起草意见，一个聚合模型汇总）中，一次「深度」预设的会诊里，聚合器反复出现如下声明：</p>
<blockquote><p>「我看到 MoA 参考模型的响应都是空的」「参考模型响应均为空，我作为聚合模型直接…」</p>
</blockquote><p>参考环节等于零信号，整轮输出实际来自聚合器单干。而用量账显示这 6 次调用真实烧掉了 76K 输入 / 10K 输出 token——模型确实在算，只是算出来的东西根本没有进入上下文。</p>
<p>更精确的证据来自对真实调用路径的探针：用与故障完全相同的代码（参考槽调用函数）复跑三个参考槽，两个槽的返回原文如下：</p>
<p><strong>实测返回（思考 token 与可见内容占比）</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">参考槽 B: completion_tokens=1200, reasoning_tokens=1193 → content=&#39;&#39;（空）
</span></span><span class="line"><span class="cl">参考槽 C: completion_tokens=1200, reasoning_tokens=1200 → content=&#39;&#39;（空）</span></span></code></pre></div></div>
<p>1200 的输出预算被思考（reasoning）吃干抹净：思考 token 占满预算（1193–1200），可见内容为 0。</p>

<h3 class="relative group">根因
    <div id="根因" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>参考槽被配置为<strong>高思考强度（reasoning_effort: high）+ 很小的输出预算（reference_max_tokens: 1200）</strong>。思考模型在把预算烧完之前不会产出可见内容；1200 的上限恰好到「想完为止」。默认档预设没炸，是因为思考强度只有 low/medium，占不满预算。关键机制有二：</p>
<ul>
<li><strong>参考模型的思考对聚合器完全不可见</strong>——参考槽从不行动，只有可见 content 会进入聚合器上下文。思考烧掉的 token 对本轮输出是纯粹的沉没成本。</li>
<li><strong>深度应该属于聚合器</strong>——聚合器拿到全量上下文、高思考强度与顶格预算（本例 8192），最终质量由聚合器对多样输入的合成决定；参考槽共享的是裁剪后的意见片段，经验数据里参考输出 token 与整轮延迟强相关（约 0.88），给参考狂加预算只会线性抬高延迟与成本，答案质量几乎不涨。正确姿势是：参考槽低强度、够用预算，深度全部押在聚合器。</li>
</ul>

<h3 class="relative group">处置过程（含弯路）
    <div id="处置过程含弯路" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>第一轮按真实请求形态单发流式探测（stream + <code>max_tokens=1200</code> + high）走出了弯路：参考槽 A 正常返回 12194 字符；参考槽 B 返回 1673 字符但 <code>finish_reason=length</code>（被 1200 截断）；参考槽 C 在探测环境被限流 403。<strong>单发都不空</strong>，一度怀疑「全空」来自别的环节。换用与故障完全同一的代码路径（真实的参考槽调用，含参数组装、缓存装饰、响应提取）复跑三个槽，空响应稳定复现：A 返回 11075 字符，B、C 均返回 <code>(empty response)</code>。</p>
<p>教训：手工单发与真实调用路径的差别（参数组装、超时、提取逻辑）足以改变结果。复现必须用真实代码路径。</p>

<h3 class="relative group">修复与验证
    <div id="修复与验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bf%ae%e5%a4%8d%e4%b8%8e%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>改法：三个参考槽思考强度 <code>high → medium</code>，<code>reference_max_tokens 1200 → 2400</code>；聚合器保持 high / 8192 不动。深度留给聚合器，参考只求多样性。修改后的配置片段：</p>





<div
  id="accordion-8f05378d1ac665568d7c2e2cbb262884"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>deep 预设修复后的配置（模型名隐去，结构按原样）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">moa</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">presets</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">reference_max_tokens</span><span class="p">:</span><span class="w"> </span><span class="m">2400</span><span class="w">        </span><span class="c"># 1200 → 2400</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">max_tokens</span><span class="p">:</span><span class="w"> </span><span class="m">8192</span><span class="w">                  </span><span class="c"># 聚合器预算，不变</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">degraded_reference_policy</span><span class="p">:</span><span class="w"> </span><span class="l">loud</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">reference_models</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">provider</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;参考通道&gt;          </span><span class="w"> </span><span class="c"># 三个参考槽</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">model</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;参考模型 A&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">reasoning_effort</span><span class="p">:</span><span class="w"> </span><span class="l">medium     </span><span class="w"> </span><span class="c"># high → medium</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">provider</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;参考通道&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">model</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;参考模型 B&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">reasoning_effort</span><span class="p">:</span><span class="w"> </span><span class="l">medium</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">provider</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;参考通道&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">model</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;参考模型 C&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">reasoning_effort</span><span class="p">:</span><span class="w"> </span><span class="l">medium</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">aggregator</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">provider</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;参考通道&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">model</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;聚合模型&gt;               </span><span class="w"> </span><span class="c"># 深度留给聚合器</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">reasoning_effort</span><span class="p">:</span><span class="w"> </span><span class="l">high         </span><span class="w"> </span><span class="c"># 不变</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-8f05378d1ac665568d7c2e2cbb262884 > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-8f05378d1ac665568d7c2e2cbb262884 > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-8f05378d1ac665568d7c2e2cbb262884 details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-8f05378d1ac665568d7c2e2cbb262884 details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-8f05378d1ac665568d7c2e2cbb262884");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


<p>验证口径：用同一段真实代码路径、同一批槽位复测，比较 content 字符数与空响应个数。修复后三个参考槽全部非空（10260 / 1026 / 3129 字符），空响应消除。此前唯一幸存的参考槽 A 从 11075 字符降到 10260，结构化分析完整保留，只是少了冗余——另外两个槽从 0 变成实打实的意见。反向口径同样明确：若复测仍出现 <code>(empty response)</code>，或字符数不升反降，说明预算依然不够、该模型的思考开销更大，继续加 <code>reference_max_tokens</code>，而不是动 effort。若再想加深参考，加预算，别加 effort：小预算下 effort 只会把预算变成隐形思考。</p>

<h2 class="relative group">二、间歇 400：推理模型 + 工具走错了端点
    <div id="二间歇-400推理模型--工具走错了端点" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e9%97%b4%e6%ad%87-400%e6%8e%a8%e7%90%86%e6%a8%a1%e5%9e%8b--%e5%b7%a5%e5%85%b7%e8%b5%b0%e9%94%99%e4%ba%86%e7%ab%af%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">症状与第一手数据
    <div id="症状与第一手数据-1" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae-1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>一个 OpenAI 兼容的中转服务（下称「中转站」）上，推理模型带工具调用时报 400，错误原文：</p>
<p><strong>400 错误原文</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">HTTP 400: Function tools with reasoning_effort are not supported for &lt;上游模型名&gt;
</span></span><span class="line"><span class="cl">in /v1/chat/completions. To use function tools, use /v1/responses or set
</span></span><span class="line"><span class="cl">reasoning_effort to &#39;none&#39;. (request id: &lt;已隐去&gt;)</span></span></code></pre></div></div>
<p>同一模型同一配置，失败却是<strong>间歇性</strong>的：连续 6 次请求出现 1 次 400，另一轮连续 8 次出现 1 次 400，多轮探测估算失败率约 5%。故障请求的落盘 JSON 显示：url 是 <code>…/v1/chat/completions</code>，body 同时携带了 <code>tools</code> 与 <code>reasoning_effort</code>——这正是「该走另一端点却走了这个端点」的现场。</p>

<h3 class="relative group">根因
    <div id="根因-1" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a0%b9%e5%9b%a0-1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>客户端有个值得先说的设计：请求失败时会连同 url、headers、body 落盘成 JSON（request_dump）——这是取证的第一现场，症状出现先翻它，而不是盯着错误码猜。本例的落盘记录表明请求确实带了 tools 与 reasoning 却发往 chat/completions 端点。上游通道对 OpenAI 系推理模型的规则：<strong>带 function tools 时，非 none 的推理强度只能走 <code>/v1/responses</code></strong>，<code>/v1/chat/completions</code> 一并携带二者会被拒。客户端把自定义 provider 默认路由到 chat/completions，于是撞上上游约束。更迷惑的是模型粒度不一致：同端点同配置，一个推理模型 high 与 none 全部 200，另一个推理模型 high 稳定 400（改成 none 又全部 200）。模型间差异叠加同模型间歇失败，观感就是「随机故障」。</p>

<h3 class="relative group">处置过程（含弯路）
    <div id="处置过程含弯路-1" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e5%bc%af%e8%b7%af-1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>第一版改法是错误文案提示的官方建议：给这些模型设 <code>reasoning_overrides: none</code>，让 chat 通道的请求不携带推理强度。实测<strong>没有真正消除 400</strong>——中转仍按上游通道施加推理——而且代价是关掉思考，不可接受，撤销。</p>
<p>教训：错误文案的「官方建议」只对特定通道成立；中转层叠了上游策略，改法要先用该通道直连探测确认，不能照单全收。</p>

<h3 class="relative group">修复与验证
    <div id="修复与验证-1" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bf%ae%e5%a4%8d%e4%b8%8e%e9%aa%8c%e8%af%81-1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>纯配置修复、源码零改动：在中转站同源（同一 base_url、同一密钥）新增一个走 Responses 协议的 provider，请求路由到 <code>…/v1/responses</code>，可同时携带 tools 与 reasoning；<strong>原 chat 通道保留</strong>，全局推理强度不动。匿名化后的配置片段：</p>





<div
  id="accordion-e4f1bcba0762b91b2b2c81da1a0bd563"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>新增 Responses 通道（匿名化版本，实际名字已隐去）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">providers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">&lt;普通通道&gt;</span><span class="p">:</span><span class="w">                    </span><span class="c"># 原有配置，保持 chat/completions 不变</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">base_url</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://api.example.invalid/v1&#34;</span><span class="w">   </span><span class="c"># 域名与端口隐去</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">key_env</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;密钥环境变量&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">discover_models</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">&lt;responses 通道&gt;</span><span class="p">:</span><span class="w">              </span><span class="c"># 新增：同一中转的 /v1/responses 通道</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;&lt;中转站名&gt;（Responses）&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">base_url</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;https://api.example.invalid/v1&#34;</span><span class="w">   </span><span class="c"># 与普通通道同源</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">key_env</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;同一个密钥环境变量&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">api_mode</span><span class="p">:</span><span class="w"> </span><span class="l">codex_responses   </span><span class="w"> </span><span class="c"># 请求发往 /v1/responses</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">discover_models</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">model</span><span class="p">:</span><span class="w"> </span><span class="l">&lt;推理模型&gt;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">models</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">&lt;推理模型&gt;</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">&lt;另一个推理模型&gt;</span><span class="p">:</span><span class="w"> </span>{}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># 只登记实测 /responses 可用的模型</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-e4f1bcba0762b91b2b2c81da1a0bd563 > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-e4f1bcba0762b91b2b2c81da1a0bd563 > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-e4f1bcba0762b91b2b2c81da1a0bd563 details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-e4f1bcba0762b91b2b2c81da1a0bd563 details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-e4f1bcba0762b91b2b2c81da1a0bd563");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


<p>保留两条通道的原因来自一张通道矩阵实测（同中转、同密钥、双端点各打一轮）：</p>
<table>
	<thead>
			<tr>
					<th>模型类别</th>
					<th><code>/v1/chat/completions</code></th>
					<th><code>/v1/responses</code></th>
					<th>结论</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>OpenAI 系推理模型</td>
					<td>带 tools + 推理间歇 400（约 5%），<code>effort=none</code> 稳定 200</td>
					<td>带 tools + 推理稳定 200</td>
					<td>走新通道</td>
			</tr>
			<tr>
					<td>另一类模型（多模态对话）</td>
					<td>稳定 200</td>
					<td>稳定 429（「当前分组上游负载已饱和」）</td>
					<td>保持 chat</td>
			</tr>
			<tr>
					<td>第三类模型（某国产旗舰）</td>
					<td>稳定 200</td>
					<td>稳定 429</td>
					<td>保持 chat</td>
			</tr>
	</tbody>
</table>
<p>矩阵里 429 的提示原文是「当前分组上游负载已饱和」——这是中转按分组转发失败的通用提示，并不代表模型本身坏了；判断通道可用性以端点的真实返回为准。</p>
<p>验证分两层：功能上，两个曾出 400 的模型经新通道带工具均跑通（其中一个再做两轮强制工具调用，均正常执行并回传输出）；结构上，翻故障请求的落盘 JSON——<code>request.url</code> 已变成 <code>…/v1/responses</code>，body keys 含 <code>model / instructions / input / tools / reasoning</code>。这就是「请求确实换了端点」的物证。</p>
<p><strong>检视请求落盘的命令</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">json</span><span class="o">,</span> <span class="nn">glob</span><span class="o">,</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="n">f</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">glob</span><span class="o">.</span><span class="n">glob</span><span class="p">(</span><span class="s2">&#34;&lt;状态目录&gt;/request_dump_*.json&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">           <span class="n">key</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">getmtime</span><span class="p">)[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">d</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="nb">open</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">r</span> <span class="o">=</span> <span class="n">d</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;request&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;url:&#34;</span><span class="p">,</span> <span class="n">r</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;url&#34;</span><span class="p">))</span>          <span class="c1"># 应指向 .../v1/responses</span>
</span></span><span class="line"><span class="cl"><span class="n">b</span> <span class="o">=</span> <span class="n">r</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;body&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="s2">&#34;body keys:&#34;</span><span class="p">,</span> <span class="nb">list</span><span class="p">(</span><span class="n">b</span><span class="o">.</span><span class="n">keys</span><span class="p">()))</span>  <span class="c1"># 应含 tools / reasoning</span></span></span></code></pre></div></div>
<p>顺带提醒：这类「同源双通道」方案要维护两份模型登记表，新模型上架时先实测它的 Responses 通道可用性再入表，别把 429 的模型硬塞进去。</p>

<h2 class="relative group">三、模型列表「被刷新掉了」：运行中的进程还在用旧内存态
    <div id="三模型列表被刷新掉了运行中的进程还在用旧内存态" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e6%a8%a1%e5%9e%8b%e5%88%97%e8%a1%a8%e8%a2%ab%e5%88%b7%e6%96%b0%e6%8e%89%e4%ba%86%e8%bf%90%e8%a1%8c%e4%b8%ad%e7%9a%84%e8%bf%9b%e7%a8%8b%e8%bf%98%e5%9c%a8%e7%94%a8%e6%97%a7%e5%86%85%e5%ad%98%e6%80%81" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">症状与第一手数据
    <div id="症状与第一手数据-2" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae-2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>某厂商官方 API 灰度发布临时内测模型（模型名自带过期标记，如 <code>…-expires-on-&lt;日期&gt;</code>）。官方 <code>/models</code> 接口只列 GA 模型、不收录灰度名，但拿模型名直接打其 chat/completions 返回 200，推理输出正常。于是把模型写进本地模型目录（catalog 静态表）、把默认模型切到它。此后：</p>
<ul>
<li>新进程里模型列表（权威构建函数）返回 4 个模型，新模型在列；</li>
<li>命令行一次性调用也能跑通；</li>
<li>但<strong>桌面端模型列表里看不到它，新对话无法调用</strong>。</li>
</ul>
<p>用户的观感是「模型列表被刷新掉了」。</p>

<h3 class="relative group">根因
    <div id="根因-2" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a0%b9%e5%9b%a0-2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>模型目录（提供模型清单的静态表）在进程 import 时整表载入内存，运行中的进程不会重新读文件。排查链逐项排除后只剩一处不过：</p>
<table>
	<thead>
			<tr>
					<th>检查点</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>官方端点是否还认这个模型名</td>
					<td>✅ 200，参数组合正常</td>
			</tr>
			<tr>
					<td>配置文件默认模型</td>
					<td>✅ 仍是新模型</td>
			</tr>
			<tr>
					<td>静态模型目录（catalog 源文件）</td>
					<td>✅ 条目还在，git 改动未被冲掉</td>
			</tr>
			<tr>
					<td>权威列表构建（新进程）</td>
					<td>✅ 返回 4 个模型，含新模型</td>
			</tr>
			<tr>
					<td>命令行一次性调用（新进程）</td>
					<td>✅ 跑通</td>
			</tr>
			<tr>
					<td><strong>正在运行的后端进程</strong></td>
					<td>❌ PID 644 / 7880（serve）启动于当天 <strong>09:00:07</strong>，早于文件改动（当天 <strong>15:44</strong>）</td>
			</tr>
	</tbody>
</table>
<p>判据就是最后一行：<strong>进程启动时间早于文件改动时间</strong>。磁盘上的一切（列表、配置、官方端点）都是对的，错的是常驻进程内存里那份 import 时载入的旧表。</p>

<h3 class="relative group">处置与验证
    <div id="处置与验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a4%84%e7%bd%ae%e4%b8%8e%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>修复 = 让后端进程重启加载新状态：用一次性计划任务在 2 分钟后执行 <code>Stop-Process</code> 杀掉 serve 进程，桌面应用会自动拉起新后端（当次对话会断一下，属预期）。</p>
<p>这类「import 时载入的静态表」不吃配置热加载——配置文件的修改多数键可以热生效，但模型目录这类进程级常量只在启动时快照一次。所以改了目录后的第一动作就是重启验证，而不是反复检查文件内容。工程上先确认「老进程确实死在改动之前」再看其它方向，这一步用一条命令就能盖棺：</p>
<p><strong>查验进程启动时间（Windows）</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">powershell.exe -NoProfile -Command <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s1">&#39;Get-CimInstance Win32_Process | Where-Object { $_.Name -match &#34;python&#34; } | \
</span></span></span><span class="line"><span class="cl"><span class="s1">   Select-Object ProcessId, CreationDate, CommandLine | Format-List&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 输出形如：PID=644  START=09/08/2026 09:00:07  ... -m hermes_cli.main --profile default serve</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 与文件改动时间比对（示例）</span>
</span></span><span class="line"><span class="cl">stat -c <span class="s1">&#39;%y&#39;</span> &lt;hermes 数据目录&gt;/config.yaml   <span class="c1"># 若晚于进程启动时间，就是内存态问题</span></span></span></code></pre></div></div>
<p><strong>调度重启（Windows 计划任务，名字已隐去）</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">schtasks /create /tn <span class="s2">&#34;&lt;任务名&gt;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  /tr <span class="s2">&#34;powershell.exe -NoProfile -Command \&#34;Stop-Process -Id &lt;旧PID&gt; -Force\&#34;&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">  /sc once /st &lt;HH:MM&gt; /f</span></span></code></pre></div></div>
<p>如何确认已经生效：重启后新开一个对话，模型标签显示新模型名即成功；若仍失败，回到排查链最后一项重新比照启动时间，或检查该灰度名是否已到期（名字里的过期标记过期后调用会报模型不存在，届时切回正式模型并还原目录条目即可）。</p>

<h2 class="relative group">四、定时任务的隐形权限坑：调度身份 ≠ 你手动跑的身份
    <div id="四定时任务的隐形权限坑调度身份--你手动跑的身份" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e5%ae%9a%e6%97%b6%e4%bb%bb%e5%8a%a1%e7%9a%84%e9%9a%90%e5%bd%a2%e6%9d%83%e9%99%90%e5%9d%91%e8%b0%83%e5%ba%a6%e8%ba%ab%e4%bb%bd--%e4%bd%a0%e6%89%8b%e5%8a%a8%e8%b7%91%e7%9a%84%e8%ba%ab%e4%bb%bd" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">症状与第一手数据
    <div id="症状与第一手数据-3" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae-3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>定时任务通知里只有被截断的 traceback（<code>Script exited with code 1</code>）；任务记录 JSON 的 <code>last_error</code> 里有完整原文：</p>
<p><strong>任务记录里的完整错误（路径已匿名化）</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Traceback (most recent call last):
</span></span><span class="line"><span class="cl">  File &#34;&lt;部署目录&gt;/&lt;脚本名&gt;.py&#34;, line 106, in &lt;module&gt;
</span></span><span class="line"><span class="cl">    save_state(state)
</span></span><span class="line"><span class="cl">  File &#34;&lt;部署目录&gt;/&lt;脚本名&gt;.py&#34;, line 45, in save_state
</span></span><span class="line"><span class="cl">    with open(STATE_FILE, &#34;w&#34;, encoding=&#34;utf-8&#34;) as f:
</span></span><span class="line"><span class="cl">PermissionError: [Errno 13] Permission denied: &#39;&lt;部署目录&gt;/&lt;状态文件&gt;.json&#39;</span></span></code></pre></div></div>
<p>脚本在发现有新版要上报、准备写状态文件时崩溃。而手动在容器里跑同一脚本：正常检测到新版、退出码 0。第一反应自然是「脚本没问题，是调度的问题」——方向对了一半，但真正的坑在下面两层。</p>

<h3 class="relative group">根因
    <div id="根因-3" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a0%b9%e5%9b%a0-3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>两层错位叠加：</p>
<ol>
<li><strong>身份错位</strong>：容器内定时任务的调度器以 uid 1000（容器内服务用户）执行脚本，但脚本与状态文件是经宿主机 root 写入的（bind mount 下 <code>root:root 0644</code>），而整个数据树其余部分都是 <code>1000:1000</code>——只有脚本目录孤零零挂在 root 名下。bind mount 的属主来源值得多说一句：宿主机 root 写入的文件挂进容器后仍是 root 属主，容器内非 root 调度身份自然写不动；数据树其余文件由容器内服务进程（uid 1000）自己创建，属主正确——所以整棵树只有脚本目录异常，特征非常清楚，一眼就该往身份错位方向查。cron 一写就 <code>PermissionError</code>；手动 <code>docker exec</code> 默认以 root 跑，当然成功，把问题盖住了。</li>
<li><strong>静默错位</strong>：脚本里把「追加侧车记录」包在 <code>except Exception: pass</code> 里——追加失败不报错、不退出、无日志。以 uid 1000 端到端验证时功能正常，但侧车应写入的第二条记录没有出现；靠对比追加记录条数才发现失败被静默吞掉。</li>
</ol>
<p><strong>脚本里静默吞异常的原样片段</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">SIDECAR</span><span class="p">,</span> <span class="s2">&#34;a&#34;</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">f</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">pass</span>      <span class="c1"># ← 追加失败被静默吞掉：不报错、不退出、无日志</span></span></span></code></pre></div></div>

<h3 class="relative group">处置
    <div id="处置" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a4%84%e7%bd%ae" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>先复现，再修复，最后端到端验证，全程用调度身份：</p>





<div
  id="accordion-7f55ce72edcf50d735d629c8144487bb"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>复现 &#43; 修复（命令已匿名化，结构按原样）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1) 以调度身份复现（关键：-u 指定 uid:gid，别用默认 root）</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u 1000:1000 &lt;容器&gt; python3 -c <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s2">&#34;open(&#39;&lt;部署目录&gt;/&lt;状态文件&gt;.json&#39;,&#39;a&#39;).write(&#39;&#39;)&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># → PermissionError: [Errno 13] Permission denied    ← 坐实身份问题</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2) 修复属主：脚本 + 状态文件</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> &lt;容器&gt; chown 1000:1000 <span class="se">\
</span></span></span><span class="line"><span class="cl">  &lt;部署目录&gt;/&lt;脚本名&gt;.py &lt;部署目录&gt;/&lt;状态文件&gt;.json
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3) 连带修复：侧车记录文件同样 root 属主，一并 chown 并实测追加</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> &lt;容器&gt; chown 1000:1000 &lt;部署目录&gt;/&lt;侧车记录文件&gt;.md
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> -u 1000:1000 &lt;容器&gt; sh -c <span class="se">\
</span></span></span><span class="line"><span class="cl">  <span class="s2">&#34;echo test &gt;&gt; &lt;部署目录&gt;/&lt;侧车记录文件&gt;.md&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> APPEND_OK</span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-7f55ce72edcf50d735d629c8144487bb > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-7f55ce72edcf50d735d629c8144487bb > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-7f55ce72edcf50d735d629c8144487bb details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-7f55ce72edcf50d735d629c8144487bb details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-7f55ce72edcf50d735d629c8144487bb");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


<p>顺手把脚本里的静默吞异常清掉：失败至少留日志，或直接让写失败可见——「不报错的失败」比报错的失败难缠一个量级。</p>

<h3 class="relative group">验证
    <div id="验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>以调度身份端到端复现完整写路径：临时把状态里的「最新已见」回退一条制造「新版」→ 以 uid 1000 跑完整脚本 → 正常检测、上报、重存状态、退出码 0；侧车追加也实测通过（APPEND_OK）。最后留一条验收纪律：<strong>手动跑成功 ≠ 定时跑成功</strong>——最终要等下一次自动调度的结果（看任务记录里的 <code>last_status</code> / <code>last_error</code>），而不是再手动跑一遍。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>场景</th>
					<th>先查什么</th>
					<th>再做什么</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>聚合/多槽响应全空</td>
					<td>用量账：<code>reasoning_tokens</code> 是否接近 <code>completion_tokens</code></td>
					<td>降思考强度或加预算；想加深时加预算，别只加 effort</td>
			</tr>
			<tr>
					<td>推理模型 + 工具偶发 400</td>
					<td>请求落盘 JSON 的 url 与 body</td>
					<td>按上游要求同源加 <code>/v1/responses</code> 通道，先实测该模型双端点再入表</td>
			</tr>
			<tr>
					<td>改配置 / 目录后界面不变</td>
					<td>进程启动时间 vs 文件改动时间</td>
					<td>重启常驻进程，再验证；对比用同一数据源</td>
			</tr>
			<tr>
					<td>定时任务与手动结果不一致</td>
					<td>调度身份 uid/gid 与文件属主</td>
					<td>用 <code>-u &lt;uid&gt;:&lt;gid&gt;</code> 复现；清掉 <code>except: pass</code>；等下一次自动跑验收</td>
			</tr>
			<tr>
					<td>一切复现的纪律</td>
					<td>用故障同一条路径、同一个身份、同一个通道</td>
					<td>别用手工近似；复现成功的那一刻，根因通常已经水落石出</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>四件事共用一条主线：故障都「长得像模型坏了」，因为诊断入口都在模型层；但每一件都在模型层之外留有确凿的系统事实——token 账、请求落盘、进程时间戳、文件属主。排障真正的分水岭不是知道更多 API 细节，而是把「复现」做成纪律：同代码路径、同身份、同通道。单发探测全绿、docker exec 跑通、新进程一切正常——这三句「看起来没问题」恰恰是前三则案例里最贵的弯路。</p>
]]></content:encoded>
    </item>
    <item>
      <title>整机备份换 7z 非固实：列出文件从好几分钟到 0.59 秒</title>
      <link>https://slop.lun.sh/backup-archive-7z-nonsolid/</link>
      <pubDate>Sat, 19 Sep 2026 00:25:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/backup-archive-7z-nonsolid/</guid>
      <category>Windows</category>
      <category>备份</category>
      <category>7z</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>重装系统前把整个用户目录（AppData）打成一个「按日期命名的整包备份」RAR，事后想取回个别文件时发现：打开包、看文件列表都要先把全档扫一遍，好几分钟起。改成 <strong>7z 非固实</strong>重打包后，列表 <strong>0.59 秒</strong>出、单文件可直接提取，顺带裁剪掉约 41 GiB 的缓存与可重装内容。适用环境：Windows 11、WinRAR 7.23、7-Zip 22.01，2026 年 8 月实测。</p>
</blockquote>
<h2 class="relative group">一、症状与第一手数据
    <div id="一症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>重装系统前，原始包是对「系统盘用户目录」做的整包备份：实测当时目录 28.2 万个文件、34.6 GB。打包本身没问题，问题出在<strong>事后取用</strong>——想打开备份看一眼清单、或取出其中某个配置文件，工具都要先把所有文件过一遍，明显等不起。</p>
<p>第一手数据（对备份包实测）：</p>
<ul>
<li>压缩后体积 <strong>36.3 GB</strong>，内部是按日期命名的 RAR5 归档；</li>
<li>完整清单统计：<strong>516,602 个文件 / 64.87 GiB</strong>（解压后 69.65 GB）；</li>
<li>读这份清单（包含列目录、解压）耗时：<strong>好几分钟</strong>。</li>
</ul>
<p>这不是压缩率的问题——体积 36 GB 对整机用户目录完全可以接受。真正的问题是<strong>容器格式的取舍</strong>。</p>
<p><strong>新旧包对比：</strong></p>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>旧包（RAR）</th>
					<th>新包（7z）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>格式</td>
					<td>非固实 RAR5，无中央目录</td>
					<td>7z 非固实（<code>-ms=off</code>），集中文件头</td>
			</tr>
			<tr>
					<td>压缩后体积</td>
					<td>36.3 GB</td>
					<td><strong>13.1 GB</strong></td>
			</tr>
			<tr>
					<td>原始数据体积</td>
					<td>64.87 GiB</td>
					<td>24.0 GiB</td>
			</tr>
			<tr>
					<td>文件数</td>
					<td>516,602</td>
					<td>62,936（12,615 个文件夹）</td>
			</tr>
			<tr>
					<td>打开文件列表</td>
					<td>扫描全档，好几分钟</td>
					<td><strong>0.59 秒</strong>（实测 0.588 秒）</td>
			</tr>
			<tr>
					<td>取单个文件</td>
					<td>顺序读取相关区块</td>
					<td>直接定位，即时取出</td>
			</tr>
			<tr>
					<td>完整性校验</td>
					<td>参考用</td>
					<td><code>Everything is Ok</code></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">二、根因：容器格式的取舍，不是压缩率
    <div id="二根因容器格式的取舍不是压缩率" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%a0%b9%e5%9b%a0%e5%ae%b9%e5%99%a8%e6%a0%bc%e5%bc%8f%e7%9a%84%e5%8f%96%e8%88%8d%e4%b8%8d%e6%98%af%e5%8e%8b%e7%bc%a9%e7%8e%87" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>先核对备份包的属性：头部只有「RAR 5, 恢复记录」，<strong>没有固实标记——这个包本身是非固实的</strong>。所以打开慢的根源不是固实，而是 <strong>RAR 没有中央目录</strong>：</p>
<ul>
<li>打开文件列表，工具要<strong>从头顺序扫描数据流里的 51.6 万条文件头</strong>才能拼出清单；</li>
<li>固实模式是<strong>另一层</strong>成本：所有文件被作为一个块压缩，取中间某个文件时，必须先解出排在它前面的全部内容（7z 默认就开固实）。RAR 勾了固实时同样如此。</li>
</ul>
<p>三种主流格式的差异：</p>
<table>
	<thead>
			<tr>
					<th>特性</th>
					<th>RAR</th>
					<th>ZIP</th>
					<th>7z</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>中央目录（打开即见全部清单）</td>
					<td>无，需顺序扫描</td>
					<td>有，集中在文件尾</td>
					<td>有，集中在头部</td>
			</tr>
			<tr>
					<td>固实模式</td>
					<td>可选（勾了才开）</td>
					<td>通常关</td>
					<td><strong>默认开</strong></td>
			</tr>
			<tr>
					<td>固实时取单个文件</td>
					<td>先解出它前面所有文件</td>
					<td>同左</td>
					<td>同左</td>
			</tr>
			<tr>
					<td>非固实时取单个文件</td>
					<td>直接取</td>
					<td>直接取</td>
					<td>直接取</td>
			</tr>
	</tbody>
</table>
<p>固实的收益是压缩率更高（小文件多、彼此相似时尤其明显），代价是随机取用极慢。RAR 的设计初衷是「整体解压」——恰好和这台机器的使用场景相反：<strong>整机用户目录备份的真实用途，是以后偶尔捞出某个配置、存档、聊天记录，不是整套恢复</strong>。</p>
<p>按用途选格式：</p>
<ul>
<li>要整体分发、长期静置 → 固实（RAR 或 7z 都行），压缩率优先；</li>
<li>要<strong>事后随机取文件</strong> → 7z 关固实（每文件独立压缩 + 集中文件头）或非固实 ZIP。ZIP 兼容性最好，7-Zip 免费且压缩率更好。</li>
</ul>
<p>还有一个旁路：干脆不打包，直接复制成普通文件夹，浏览、搜索、单文件恢复全部瞬时，还能保留时间戳与 ACL：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">robocopy <span class="s2">&#34;%USERPROFILE%\AppData&#34;</span> <span class="s2">&#34;数据盘:\Backup\AppData&#34;</span> /E /XJ /MT:16 /R:1 /W:1 /COPY:DAT</span></span></code></pre></div></div>
<p>本例没有走这条路：明文目录会被快捷启动类工具的索引扫到、还会把文件列表弄脏。<strong>保持归档形态</strong>，只做「裁剪 + 换格式」。</p>

<h2 class="relative group">三、处置：先分析清单，再裁剪，最后换格式重打包
    <div id="三处置先分析清单再裁剪最后换格式重打包" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e5%85%88%e5%88%86%e6%9e%90%e6%b8%85%e5%8d%95%e5%86%8d%e8%a3%81%e5%89%aa%e6%9c%80%e5%90%8e%e6%8d%a2%e6%a0%bc%e5%bc%8f%e9%87%8d%e6%89%93%e5%8c%85" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">3.1 全量解压，但先读清单、做体积分布分析
    <div id="31-全量解压但先读清单做体积分布分析" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#31-%e5%85%a8%e9%87%8f%e8%a7%a3%e5%8e%8b%e4%bd%86%e5%85%88%e8%af%bb%e6%b8%85%e5%8d%95%e5%81%9a%e4%bd%93%e7%a7%af%e5%88%86%e5%b8%83%e5%88%86%e6%9e%90" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>不能凭印象删 64.87 GiB 的东西。先用 WinRAR 命令行导出完整清单（GBK 编码、51.6 万行），再用脚本按目录聚合体积与文件数分布：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="s2">&#34;C:\Program Files\WinRAR\Rar.exe&#34;</span> l -v <span class="s2">&#34;外置盘:\整包备份-20260827.rar&#34;</span> &gt; 清单.txt</span></span></code></pre></div></div>
<p>分析一出，垃圾大头立刻现形：显卡驱动残留、pip/npm/uv 缓存、可重装软件本体、一堆 <code>*-updater</code> 下载缓存、Temp 与崩溃转储。接着全量解压（后台跑，-mmt 多线程）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="s2">&#34;C:\Program Files\7-Zip\7z.exe&#34;</span> x -y <span class="s2">&#34;外置盘:\整包备份-20260827.rar&#34;</span> -o<span class="s2">&#34;D:\AppDataTrim\extract&#34;</span> -mmt<span class="o">=</span>on</span></span></code></pre></div></div>
<p>结果与清单一致：<code>Everything is Ok</code>，60,811 个文件夹 / 455,791 个文件 / 69.65 GB。</p>

<h3 class="relative group">3.2 裁剪清单：一类一类删，标准只有一条
    <div id="32-裁剪清单一类一类删标准只有一条" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#32-%e8%a3%81%e5%89%aa%e6%b8%85%e5%8d%95%e4%b8%80%e7%b1%bb%e4%b8%80%e7%b1%bb%e5%88%a0%e6%a0%87%e5%87%86%e5%8f%aa%e6%9c%89%e4%b8%80%e6%9d%a1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>判断标准可以浓缩成一句：<strong>可重装的、可再下载的、纯缓存与日志的，删；不可再生的，留</strong>。逐类过：</p>
<table>
	<thead>
			<tr>
					<th>类别</th>
					<th>典型内容</th>
					<th>判断标准</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>显卡驱动残留</td>
					<td>NVIDIA/AMD 本地组件与着色器缓存（Local/LocalLow）、D3DSCache</td>
					<td>重装系统或更新驱动后自动重建，纯残留</td>
			</tr>
			<tr>
					<td>包管理器缓存</td>
					<td>pip 约 3.0 GB、npm-cache 约 1.6 GB（7.8 万个文件）、uv 约 1.1 GB</td>
					<td>随时可以重新下载；删缓存不影响已装包</td>
			</tr>
			<tr>
					<td>可重装软件本体</td>
					<td><code>Local\Programs</code> 安装目录 4.9 GB、包管理器安装缓存</td>
					<td>重装可得，备份里留它等于白背一遍</td>
			</tr>
			<tr>
					<td>浏览器与旧版工具数据</td>
					<td>浏览器整个目录、旧版自动化工具数据目录</td>
					<td>本例已在新系统恢复完毕，确认后整目录删除</td>
			</tr>
			<tr>
					<td>微软各类缓存</td>
					<td>WinGet（0.64 GB）、OneDrive（0.52 GB）、Office 组件包（0.63 GB）、RDP 缩略图、浏览器缓存类</td>
					<td>全部是缓存与组件包，重登/重装后再次生成</td>
			</tr>
			<tr>
					<td>UWP 系统包</td>
					<td><code>Packages</code> 下微软商店与系统包</td>
					<td>可自动重装；WSL 发行版是例外，见保留清单</td>
			</tr>
			<tr>
					<td>临时目录与崩溃转储</td>
					<td>Temp、CrashDumps</td>
					<td>纯垃圾</td>
			</tr>
			<tr>
					<td>自动更新器下载缓存</td>
					<td>各类 <code>*-updater</code> 目录合计约 2.4 GB、游戏平台 HTML 缓存、在线音乐客户端缓存</td>
					<td>更新完成即失效，重装后重新下载</td>
			</tr>
	</tbody>
</table>
<p>裁剪脚本实测单轮释放 <strong>16.09 GiB、229 个条目</strong>；再算上整目录删除与两处「移出」（见下），原始数据从 64.87 GiB 降到 24.0 GiB，约 41 GiB 消失，文件数从 51.6 万降到 6.3 万。</p>
<p><strong>保留清单</strong>（按不可再生性，反着过的）：</p>
<ul>
<li>邮件客户端的邮件库——账号、邮件、签名，重装后几乎无法重建；</li>
<li>聊天记录——IM 本地数据与收发过的文件；</li>
<li>输入法配置——用户词典、皮肤与方案，重装后难以原样找回来；</li>
<li>游戏存档——<code>LocalLow</code> 下各工作室的存档目录，游戏可重装、进度不能；</li>
<li>WSL 发行版磁盘镜像——一个发行版约 3.8 GB，之后可用 <code>wsl --import</code> 恢复；</li>
<li>用户字体——本例 142 个字体文件直接「归位」到新系统用户字体目录并注册 HKCU（663 个字族，无需重启生效），不再进备份包；</li>
<li>密码管理器、代理软件、SSH 客户端、快捷启动工具、远程控制软件的配置——量小、不可再生。</li>
</ul>

<h3 class="relative group">3.3 过程事故：重写脚本时弄丢一个 keep 判断
    <div id="33-过程事故重写脚本时弄丢一个-keep-判断" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#33-%e8%bf%87%e7%a8%8b%e4%ba%8b%e6%95%85%e9%87%8d%e5%86%99%e8%84%9a%e6%9c%ac%e6%97%b6%e5%bc%84%e4%b8%a2%e4%b8%80%e4%b8%aa-keep-%e5%88%a4%e6%96%ad" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>裁剪脚本中途被重写过一次（为纳入「字体移出、浏览器整删」两个调整），重写时 <code>delete_children</code> 里<strong>丢了 <code>keep</code> 判断</strong>——结果把用户明确要求保留的 <strong>WSL 发行版包（3.83 GB）当成「非 WSL 系统包」删掉了</strong>。脚本输出里赫然列着这条误删记录。</p>
<p>处置：<strong>原始包原封未动</strong>，直接从它重新提取这个包，指定路径、不动其他内容：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="s2">&#34;C:\Program Files\7-Zip\7z.exe&#34;</span> x -y <span class="s2">&#34;外置盘:\整包备份-20260827.rar&#34;</span> -o<span class="s2">&#34;D:\AppDataTrim\extract&#34;</span> <span class="s2">&#34;AppData\Local\Packages\&lt;WSL发行版目录&gt;\*&#34;</span></span></span></code></pre></div></div>
<p>恢复 3.83 GiB 并核验结构通过，无数据损失。中间还有个插曲：第一次提取报「找不到文件」，一度以为原始包丢了——核对磁盘列表后确认只是<strong>外置盘重新插拔后盘符变了</strong>，改一下命令里的盘符即恢复。</p>
<p><strong>教训，两条都值钱：</strong></p>
<ol>
<li>任何「批量删除」脚本，先 <strong>dry-run</strong>：只列清单、不执行，人工过目一遍再放行；</li>
<li><strong>原始包保留到新版验收通过为止</strong>——这次如果没有原封未动的原包，3.8 GB 的 WSL 磁盘镜像就真的没了。</li>
</ol>

<h3 class="relative group">3.4 换格式重打包：7z 非固实
    <div id="34-换格式重打包7z-非固实" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#34-%e6%8d%a2%e6%a0%bc%e5%bc%8f%e9%87%8d%e6%89%93%e5%8c%857z-%e9%9d%9e%e5%9b%ba%e5%ae%9e" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>对裁剪后的目录重打包，命令原样：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">cd</span> /d/AppDataTrim/extract
</span></span><span class="line"><span class="cl"><span class="s2">&#34;C:\Program Files\7-Zip\7z.exe&#34;</span> a -t7z <span class="s2">&#34;外置盘:\整包备份-20260827_trimmed.7z&#34;</span> AppData -ms<span class="o">=</span>off -mx<span class="o">=</span><span class="m">1</span> -mmt<span class="o">=</span>on -mhc<span class="o">=</span>on</span></span></code></pre></div></div>
<p>三个参数的含义：</p>
<ul>
<li><code>-ms=off</code>——关闭固实，每个文件独立压缩，随机取用无需前置解压；</li>
<li><code>-mx=1</code>——压缩级别 1（快、省内存；对本就高度压缩的缓存/数据库类数据影响不大）；</li>
<li><code>-mhc=on</code>——开启头压缩，文件头块紧凑，打开时读取更快。</li>
</ul>
<p>打包耗时约 7 分钟（约 1 GB/分钟），产出 <strong>13.1 GB</strong>，<code>Everything is Ok</code>。</p>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>验收看四个可观察指标，全部通过：</p>
<ol>
<li><strong>打开列表速度</strong>：<code>time 7z l 包名.7z</code> 实测 <code>real 0m0.588s</code>——对照旧包的好几分钟；输出确认 62,936 个文件、12,615 个文件夹。</li>
<li><strong>完整性校验</strong>：<code>7z t</code> 全量读取 13 GB，<code>Everything is Ok</code>、EXIT=0，Size 25.7 GB → Compressed 13.1 GB 与打包日志一致。</li>
<li><strong>裁剪结果对账</strong>：<code>Packages</code> 下只剩 WSL 发行版；邮件库、聊天记录、输入法、游戏存档、工具配置等保留项逐一确认存在；字体已在新系统生效。</li>
<li><strong>取单个文件</strong>：非固实下按路径直接指定解压即可，不依赖任何前置内容——这本来就是换格式的目的。</li>
</ol>
<p>怎么判断「没解决」：列清单仍要数秒以上、<code>7z t</code> 报错、任一保留项缺失、取出的文件与原始目录不一致——出现任意一条都要回到第三步查脚本。</p>
<p><strong>收尾</strong>：原包先不删，新包验收通过后再处理，可腾出 36 GB；24 GB 的解压临时副本清理掉，分析脚本与清单保留备查。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>按取用方式选格式</strong>：随机捞文件的备份用 7z 非固实（<code>-ms=off</code>）或非固实 ZIP；整体分发才用固实。</li>
<li><strong>打开慢先查两件事</strong>：有没有中央目录（RAR 没有，ZIP/7z 有）、有没有开固实（7z 默认开）。两者都会让「看清单/取单文件」退化成全档扫描。</li>
<li><strong>裁剪先分析后动手</strong>：先用命令导出完整清单做体积分布，一类一类过；删除标准就一句「可重装、可再下载、纯缓存 → 删」。</li>
<li><strong>先归位再删除</strong>：字体、WSL 这类要保留的，先复制/导入到新系统，再从备份里移除，账目才对得上。</li>
<li><strong>批量删除脚本的纪律</strong>：先 dry-run 只列清单；keep 判断做成显式白名单，改名/重写脚本时逐行核对；原始备份保留到新版验收通过。</li>
<li><strong>同场景可直接抄</strong>：<code>pip cache purge</code>/<code>npm cache clean --force</code>/<code>uv cache clean</code> 之外，那些 <code>*-updater</code> 目录和 <code>Temp</code>、<code>CrashDumps</code> 是每台 Windows 机器都有的同款垃圾。</li>
<li><strong>再打包命令模板</strong>（本轮实测校验过）：导出清单 → 解压 → 裁剪 → <code>7z a -t7z &quot;外置盘:\整包备份-…_trimmed.7z&quot; AppData -ms=off -mx=1 -mmt=on -mhc=on</code> → <code>7z t</code> 全量校验，一条流水线下来，最后清理解压与清单的临时文件。</li>
</ul>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这台机器最大的收获不是省下的几十 GB，而是想明白「备份格式要跟着取用方式走」。整包归档适合一次性整体恢复，而整机用户目录备份的真实身份是「数据保险库」——平时不碰，偶尔开柜子拿一件东西。保险库的门，不该每次开都重新搬一遍全馆。</p>
<p>至于那次误删，事后看完全是纪律问题：重写脚本时「顺手」丢了条件判断，没有 dry-run 就执行。原包原封未动是最后的兜底，但兜底不应该成为常态。先列清单，再动手删——这句话值得贴在每个批量操作脚本的第一行注释里。</p>
]]></content:encoded>
    </item>
    <item>
      <title>路由器定时重启后「起不来」：内部控制面没初始化，断电冷启动才恢复</title>
      <link>https://slop.lun.sh/router-boot-control-plane-failure/</link>
      <pubDate>Sat, 19 Sep 2026 00:20:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/router-boot-control-plane-failure/</guid>
      <category>路由器</category>
      <category>日志分析</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一台国产家用路由器在定时重启后突然&quot;起不来&quot;：指示灯全亮、电源和网络都在，但电脑有线连不上、手机 App 也连不上路由器，只有拔电重插才恢复。导出系统日志逐行看下来，问题不是&quot;没网&quot;，而是设备内部控制面根本没有初始化成功——配置库空表、桥端口和 WiFi 实例全部取不到，初始化流程还在自我打转。适用环境：中兴 ZXHN 系列家用路由器固件（日志字符串特征一致，型号与固件版本未在日志中给出），日志跨度 2026-09-10 至 09-14，共 161 行 Warning/Error。一句话结论：软重启不会重建已经空掉的配置库，只有完全断电的冷启动才走完整初始化路径——遇到&quot;看着启动了、管理面和局域网一起失效&quot;，先断电 30 秒再上电。</p>
</blockquote>
<h2 class="relative group">一、症状与第一手数据
    <div id="一症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>背景：路由器设了每周六凌晨自动重启，此前从没出事。上周末（周六凌晨）定时重启后开始出问题：电脑有线直连不通，手机 App 也连不上路由器，一直持续到人工断电重启才恢复。这不是&quot;上不了网&quot;那种 WAN 掉线——是局域网内部、管理面全部失效，设备自己却照常&quot;活着&quot;。</p>
<p>日志概况：导出的系统日志共 161 行，只有 Warning/Error，时间跨度 09-10 至 09-14；其中 120 行带真实时间戳，41 行的时间戳是 <code>P0000-00-00T</code>（开机时尚未同步时钟，全部挤在开机后第 33–39 秒，行号 7–47）。日志时间戳带 <code>Z</code> 后缀（UTC），正文统一换算成北京时间（UTC+8）描述。</p>
<p>最扎眼的一段（时钟未同步的那次开机）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">P0000-00-00T00:00:33 [Warning] call dbAPIQryView failed!IF_ID[DEV.IP.IF1], count[0]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:34 [Warning] call dbAPIQryView failed!IF_ID[DEV.IP.IF2], count[0]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:34 [Warning] Call function failed!Get if base info[DEV.ETH.LINK1]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:35 [Warning] call dbAPIQryView failed!IF_ID[DEV.BRIDGING.BR1.BRPORT2], count[0]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:35 [Warning] Call function failed!Get if base info[DEV.BRIDGING.BR1.BRPORT2]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:35 [Warning] Call function failed!Get if base info[DEV.BRIDGING.BR1.BRPORT16]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:35 [Warning] Call function failed!Get if base info[DEV.WIFI.SSID1]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:35 [Warning] Call function failed!Get if base info[DEV.WIFI.SSID8]
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:35 [Warning] Call function failed! start Slaac Inst
</span></span><span class="line"><span class="cl">P0000-00-00T00:00:39 [Warning] [dnsmasq] bind interface socket failed 99</span></span></code></pre></div></div>
<p>带真实时间戳那次（09-14 凌晨 04:47–04:54，同类风暴在 3 分钟内出现两轮，逐行几乎相同）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">2026-09-13T20:47:52Z [Warning] call dbAPIQryView failed!IF_ID[DEV.IP.IF1], count[0]
</span></span><span class="line"><span class="cl">2026-09-13T20:47:54Z [Warning] Call function failed!Get if base info[DEV.BRIDGING.BR1.BRPORT2]
</span></span><span class="line"><span class="cl">2026-09-13T20:47:54Z [Warning] Call function failed!Get if base info[DEV.WIFI.SSID4]
</span></span><span class="line"><span class="cl">2026-09-13T20:48:03Z [Warning] parameter is illegal!already has a Inst DEV.WIFI.SSID1
</span></span><span class="line"><span class="cl">2026-09-13T20:49:55Z [Warning] call dbAPIQryView failed!IF_ID[DEV.IP.IF1], count[0]
</span></span><span class="line"><span class="cl">2026-09-13T20:50:06Z [Warning] parameter is illegal!already has a Inst DEV.WIFI.SSID1
</span></span><span class="line"><span class="cl">2026-09-13T20:50:45Z [Error] Web user is authenticated fail from the host([内网IP]).authCode == 201</span></span></code></pre></div></div>
<p>（<code>[内网IP]</code> 为原文 IP 的脱敏写法。）更早几天还有零散的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">2026-09-10T00:41:21Z [Warning] record not find,function getQueryRecord,ip [内网IP]
</span></span><span class="line"><span class="cl">2026-09-10T13:01:27Z [Warning] Call function failed!slaacFindByIFName, IFName:br0
</span></span><span class="line"><span class="cl">2026-09-11T04:42:06Z [Warning] PADT Received</span></span></code></pre></div></div>
<p>逐类统计（同一份日志里的出现次数）：接口视图查询返回 0 行（<code>count[0]</code>）35 处；各类接口基础信息获取失败 81 处；<code>already has a Inst</code> 三组共 18 条；SLAAC 相关失败 14 条；<code>dnsmasq bind socket failed 99</code> 2 处；<code>PADT Received</code> 3 处；<code>Ioctl failed!</code> 3 处。</p>
<p>日志行 → 含义：</p>
<table>
	<thead>
			<tr>
					<th>日志原文（节选）</th>
					<th>含义</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>call dbAPIQryView failed!IF_ID[DEV.IP.IF1], count[0]</code>（35 处）</td>
					<td>接口数据模型查询返回 0 行——路由器自己的配置/接口数据库是空的</td>
			</tr>
			<tr>
					<td><code>Get if base info[DEV.BRIDGING.BR1.BRPORT2]</code>…<code>[BRPORT16]</code> 逐个失败</td>
					<td>网桥端口没注册进桥接口，有线侧数据通路没建起来——对应&quot;电脑有线连不上&quot;</td>
			</tr>
			<tr>
					<td><code>Get if base info[DEV.WIFI.SSID1]</code>…<code>[SSID8]</code>、<code>[STAPROFILE1/2]</code> 逐个失败</td>
					<td>8 个 SSID 与 STA 模板实例全部取不到——对应&quot;手机连不上 WiFi&quot;</td>
			</tr>
			<tr>
					<td><code>parameter is illegal!already has a Inst DEV.WIFI.SSID1</code>…（18 条）</td>
					<td>初始化时重复创建&quot;已存在&quot;的实例——初始化逻辑在自我打转，旧状态没清干净</td>
			</tr>
			<tr>
					<td><code>Call function failed! start Slaac Inst</code>（3 处）/ <code>slaacFindByIFName, IFName:br0</code>（11 处）</td>
					<td>IPv6 SLAAC 实例启动/查找失败，IPv6 侧一直起不来</td>
			</tr>
			<tr>
					<td><code>[dnsmasq] bind interface socket failed 99</code>（2 处）</td>
					<td>errno 99 = EADDRNOTAVAIL：要绑的接口在内核里还不存在，LAN 的 DHCP/DNS 没起来（该机型此告警是慢性常见项，不能单独当根因）</td>
			</tr>
			<tr>
					<td><code>Web user is authenticated fail … authCode == 201</code></td>
					<td>管理面 Web 服务活着、能应答请求，但账号/记录库校验不通过——对应&quot;App、网页登录不上&quot;</td>
			</tr>
			<tr>
					<td><code>PADT Received</code>（3 处）</td>
					<td>PPPoE 会话被对端断开——WAN 侧伴随现象，解释不了局域网内也连不上</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">二、根因
    <div id="二根因" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>一句话：不是&quot;重启没起来&quot;，而是起来了、但内部控制面没初始化成功——配置/接口数据库是空的，管理面（网页、App）与局域网侧（桥、DHCP）一起失效。</p>
<p>证据链分四段：</p>
<ol>
<li><code>count[0]</code>×35：配置层查出来就是空表，连&quot;接口&quot;这种最底层的数据模型都没有。</li>
<li>桥端口 <code>BRPORT2–BRPORT16</code> 与 WiFi <code>SSID1–SSID8</code> 逐个取不到：有线、无线两侧的实例都没建成，正好对应两类症状。</li>
<li><code>already has a Inst</code>×18：初始化器一边&quot;查不到实例&quot;，一边又拒绝重复创建——两条流程在空库上反复撞车，始终进不了稳定态；04:47 与 04:49 两轮逐行几乎相同的风暴就是它重跑的现场。</li>
<li><code>authCode == 201</code>：管理面进程活着（能应答并拒绝认证），坏的是后端记录库——所以&quot;管理页面打不开&quot;不是页面没起来，是后端查不到账号/配置。</li>
</ol>
<p>为什么&quot;重启&quot;反而更糟、只有冷启动才行：这类固件的软重启（网页/定时重启）不清空内存里的进程状态与半初始化实例；这次启动早期就把配置库读空了，软重启后再跑一遍，读到的还是同一份空库/残留状态，于是两轮风暴逐行相同。断电冷启动会清空内存与残留状态，从闪存冷加载配置并重建数据模型，才走通完整初始化路径。这也解释了 09-11 之后日志里那段&quot;静默空窗&quot;：设备既没正常报错、也没恢复，是典型的&quot;系统起来了、控制面卡住&quot;的假死。</p>
<p>可能的诱因（按可能性排序）：定时重启与无声固件升级/运营商配置下发撞车（该系列有&quot;无提示自动升级、升级后要断电恢复&quot;的社区反馈）；配置/账号库被写坏（<code>count[0]</code>、<code>authCode 201</code> 都指向这个方向）；电源老化或电容衰减导致复位不完全（只有冷启动能恢复是它的典型表现）。WAN 侧（PADT、DHCPv6 失败）只影响上网，解释不了局域网内失效，判为伴随现象。</p>

<h2 class="relative group">三、处置过程（含走过的弯路）
    <div id="三处置过程含走过的弯路" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e8%b5%b0%e8%bf%87%e7%9a%84%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>弯路一：只盯最显眼的错误行会跑偏。<code>dnsmasq bind interface socket failed 99</code> 在社区日志里是慢性常见告警，单独拿它当根因就错了；真正有指向性的是成组的 <code>count[0]</code> 与实例取不到。弯路二：时间戳要换算。日志带 <code>Z</code> 后缀，不换算成北京时间会把风暴时刻看偏 8 小时（若设备实际写本地时间而误标 <code>Z</code>，则所有事件整体前移 8 小时，结论不变，只是时刻对不上号）。弯路三：反复软重启没有意义——本次故障正是软重启引起，重跑几遍结果逐行相同。</p>
<p>实际处置按顺序五步：</p>
<ol>
<li>直接断电，等 30 秒再上电（冷启动，不是软重启）。</li>
<li>起来后先确认管理页面能进、WiFi 列表恢复，再导出/备份当前配置留档。</li>
<li>关掉&quot;每周定时重启&quot;开关（诱因所在，且该系列定时重启有&quot;无论选周几实际变成每天&quot;的已知问题）；确需定期重启，改用智能插座定时断电 30 秒。</li>
<li>备份后做一次恢复出厂 + 重新配置，清掉可能损坏的配置/账号库。</li>
<li>若复发且与温度、通电时长相关，按电源/硬件老化处理（换同规格适配器、清灰、避免叠放）；运营商定制机可直接报修。</li>
</ol>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>恢复的可观测指标：断电上电后管理页面能正常打开并登录，手机能看到并连上原来的 SSID——这是用户实测的恢复结果。怎么判断&quot;没解决&quot;：冷启动后这几项仍不恢复，说明不是初始化失败，而是固件/配置库问题（考虑恢复出厂）或硬件层（换电源/报修）；若恢复后日志里继续出现 <code>slaacFindByIFName</code> 重试、<code>Ioctl failed!</code> 等零星残留，说明 IPv6 侧与驱动层没有完全干净，值得继续观察。</p>
<p>下次再遇到时的 5 分钟判别法（不依赖进入后台）：</p>
<ul>
<li>手机看不到 SSID → WiFi 侧没起来；</li>
<li>看得到 SSID、连上后打不开管理页 → 控制面/管理面问题，不是信号问题；</li>
<li>电脑设静态内网 IP 后能开登录页但提示认证失败 → 印证 <code>authCode 201</code>（后端数据库坏）；</li>
<li>连登录页都不通 → 管理进程或桥没起来。</li>
</ul>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>处置步骤表：</p>
<table>
	<thead>
			<tr>
					<th>步骤</th>
					<th>操作</th>
					<th>目的/验证</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>断电 30 秒再上电（冷启动）</td>
					<td>强制走完整初始化；起来后管理页能进、WiFi 恢复即确诊&quot;初始化失败&quot;</td>
			</tr>
			<tr>
					<td>2</td>
					<td>导出配置、记录管理密码</td>
					<td>为第 4 步恢复出厂兜底</td>
			</tr>
			<tr>
					<td>3</td>
					<td>关掉定时重启与自动更新开关</td>
					<td>消除诱因；确需定期重启改用智能插座断电</td>
			</tr>
			<tr>
					<td>4</td>
					<td>备份后恢复出厂 + 重配</td>
					<td>清掉损坏的配置/账号库（<code>authCode 201</code> 指向它）</td>
			</tr>
			<tr>
					<td>5</td>
					<td>冷启动仍复现才升级固件/换电源</td>
					<td>升级前先导出配置；按硬件老化处理</td>
			</tr>
	</tbody>
</table>
<p>预防三件事：管理密码与配置导出留档（恢复出厂、刷机前必须有）；不要用软重启代替断电（本案例里软重启恰是故障来源）；固件升级前先导出配置，升级后第一周留意凌晨定时重启是否异常。</p>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这类故障最坑人的是&quot;看起来全好&quot;——灯全亮、进程在跑、电源网络都在，只有试了才知道管理面和局域网一起废了。日志里那段 41 行的 <code>P0000</code> 是同一种风暴的又一次出场，只是那次开机连时间都没来得及同步；也就是说这台设备其实反复发作过，只是前几次没被撞上而已。把&quot;断电 30 秒&quot;放在所有折腾的第一步，能省下大半排查时间。</p>
]]></content:encoded>
    </item>
    <item>
      <title>开源仓库历史里泄漏了本机路径：改写历史之后，还差删库重建这一步</title>
      <link>https://slop.lun.sh/git-history-leak-purge/</link>
      <pubDate>Fri, 18 Sep 2026 20:15:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/git-history-leak-purge/</guid>
      <category>git</category>
      <category>隐私门禁</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一个准备开源的小工具仓库，历史提交里的一行注释残留着开发机绝对路径（形如 <code>D:\某目录\...</code>）。当前文件早已清干净，直到有人把某个历史 commit 的链接发来，才发现<strong>历史里 5 个 commit 仍带着它</strong>——改文件根本改不到历史。本文记录 <code>git filter-repo</code> 全历史改写、逐提交重签 GPG、强推，以及最终删库重建的完整过程。结论先说：<strong>改写历史不等于清除历史，公开平台会缓存不可达对象</strong>；防再犯要靠提交前的自动门禁。适用环境 Windows 11 + Git for Windows（bash）+ Python 3.11，处理时间 2026-09-13。</p>
</blockquote>
<h2 class="relative group">一、症状与第一手数据
    <div id="一症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>仓库公开后不久（2026-09-13），有人把某个历史提交的链接发给站主，问了一个问题：这个提交的修改记录里暴露了本机路径（可能之前的提交也有），有什么好的处理方法吗。</p>
<p>这个提交本身是一条「chore: 移除注释中的本机路径（脱敏）」。也就是说，最早发现泄漏后已经修过一轮：仓库公开前做最终审计时，在构建脚本 <code>web/build_web.py</code> 第 6 行抓到了泄漏——那行注释里直接写了开发机绝对路径（形如 <code>D:\某目录\某仓库</code>）。当时的处理是把注释改成「项目根目录」，提交（就是那条「脱敏」提交）、重打 tag、重建 Release——<strong>当前内容已经干净</strong>。但问题恰恰出在这次&quot;修复&quot;本身：它的 diff 显示的是被删除的那一行，而那一行里就是完整路径。任何人打开这个提交的页面都能看到路径；更糟的是，之前的提交里文件还带着它。</p>
<p>用一条命令把全历史每个提交的 tree 都扫一遍（关键词换成你自己的泄露特征词）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="k">for</span> c in <span class="k">$(</span>git rev-list --all<span class="k">)</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">hits</span><span class="o">=</span><span class="k">$(</span>git grep -l -I -E <span class="s2">&#34;本地路径特征词|AppData|C:\\Users&#34;</span> <span class="nv">$c</span> -- 2&gt;/dev/null<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$hits</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$c</span><span class="s2"> </span><span class="k">$(</span>git log -1 --format<span class="o">=</span>%s <span class="nv">$c</span><span class="k">)</span><span class="s2">: </span><span class="nv">$hits</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div></div>
<p>结果很干净利落：<strong>泄漏只在一行注释里，且只出现在 5 个历史 commit</strong>（全部指向同一个文件 <code>web/build_web.py</code>）；提交信息本身没有任何痕迹。当时的提交顺序也印证了泄漏路径：最初的 feat 提交新增这一行，其后 4 个中间提交一直带着它，直到「脱敏」提交删除——删除只是最后一步，前 5 个版本里它一直在。全仓库当时共 7 个 commit、2 个 tag。</p>

<h2 class="relative group">二、根因
    <div id="二根因" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Git 的提交是一个内容寻址链表：commit 对象指向 tree，tree 再指向每个文件的 blob。改掉当前文件再提交，只会在新 commit 里换上新 blob；<strong>历史上每一个旧 commit 仍指向自己那份旧内容</strong>。所以&quot;文件已清理&quot;和&quot;历史已清理&quot;是两回事。</p>
<p>第二个坑更隐蔽：&ldquo;脱敏&quot;提交本身把路径搬进了 diff。git 的提交记录是公开面的直接表达，任何一次提交的增删行都会永久展示；即使那条提交的目的是删除，被删除的原文也完整留在页面上。</p>
<p>还有一个教训：首轮修复时宣称&quot;公开前做了最终 grep 复查&rdquo;，但那次复查本质只扫了工作区/当前内容，没有遍历 <code>git rev-list --all</code> 的每一个提交。<strong>用 git grep 检查当前文件，是发现不了历史泄漏的。</strong></p>

<h2 class="relative group">三、处置过程（含走过的弯路）
    <div id="三处置过程含走过的弯路" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e8%b5%b0%e8%bf%87%e7%9a%84%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">1. 改写前先备份
    <div id="1-改写前先备份" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#1-%e6%94%b9%e5%86%99%e5%89%8d%e5%85%88%e5%a4%87%e4%bb%bd" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>用 <code>git bundle</code> 把全部 refs 打包成一份备份（改动前的历史以此为准），再装 <code>git-filter-repo</code>（pip 安装）。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git bundle create &lt;备份路径&gt; --all
</span></span><span class="line"><span class="cl">python -m pip install git-filter-repo</span></span></code></pre></div></div>
<p>注意：这份备份打包的是<strong>旧的、含路径的历史</strong>，它本身绝不能推出去，确认一切稳定后可删。</p>

<h3 class="relative group">2. filter-repo 全历史替换
    <div id="2-filter-repo-全历史替换" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#2-filter-repo-%e5%85%a8%e5%8e%86%e5%8f%b2%e6%9b%bf%e6%8d%a2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>准备替换表，<strong>长串优先</strong>（避免短串先命中、长串匹配不到），把路径特征替换成无信息的占位注释：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">D:\某目录\某仓库==&gt;项目根目录
</span></span><span class="line"><span class="cl">D:\某目录==&gt;项目根目录</span></span></code></pre></div></div>
<p>执行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python -m git_filter_repo --replace-text 替换表.txt --force</span></span></code></pre></div></div>
<p>实际输出：<code>Parsed 7 commits</code>，<code>New history written in 0.31 seconds; now repacking/cleaning...</code>，<code>Completely finished after 0.72 seconds</code>。对小型仓库，改写本身是秒级操作。替换表里的规则一长一短，正是为了先命中长串：若短串先匹配，长串会被截成残片，替换结果不干净。</p>
<p>一个值得注意的副产物：那条「移除注释中的本机路径」的提交，唯一改动就是删除路径行；替换后它的内容与父提交一致、diff 为空，<strong>被 git-filter-repo 自动当空提交清理</strong>（7→6 个提交）。&ldquo;清路径&quot;的提交从历史上消失了——这反而是好事，因为它不再作为&quot;把路径亮出来&quot;的页面存在。改写后全历史重扫应为空，两个 tag 按内容自动重指到改写后的对应提交。</p>

<h3 class="relative group">3. 逐提交重新 GPG 签名
    <div id="3-逐提交重新-gpg-签名" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#3-%e9%80%90%e6%8f%90%e4%ba%a4%e9%87%8d%e6%96%b0-gpg-%e7%ad%be%e5%90%8d" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>历史改写必然使所有提交哈希变化，GPG 签名（基于提交内容）全部失效，<code>git log</code> 里签名状态显示 <code>N</code>。用 <code>rebase --root</code> 对每个提交重新签名：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git rebase --root --exec <span class="s1">&#39;git commit --amend --no-edit -S&#39;</span></span></span></code></pre></div></div>
<p>（gpg 不在 PATH 时，命令要加 <code>-c gpg.program=&lt;gpg 可执行文件路径&gt;</code>。）重签后全部提交显示 <code>G</code>（Verified）；两个 tag 用 <code>-a -s</code> 重新打签名，<code>git tag -v</code> 校验输出显示签名算法为 EDDSA，随后强推。</p>

<h3 class="relative group">4. 上防再犯门禁：check_privacy.py + CI + pre-commit
    <div id="4-上防再犯门禁check_privacypy--ci--pre-commit" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#4-%e4%b8%8a%e9%98%b2%e5%86%8d%e7%8a%af%e9%97%a8%e7%a6%81check_privacypy--ci--pre-commit" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>趁历史改写完、还没推上去的窗口，写了一个不到 70 行的隐私检查脚本 <code>scripts/check_privacy.py</code>：遍历 git 跟踪文件（或只查暂存区），命中即退出码 1。规则如下：</p>
<table>
	<thead>
			<tr>
					<th>规则（正则）</th>
					<th>识别目标</th>
					<th>示例命中</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>[A-Za-z]:[\\/]</code>（带负向断言，避免误伤 <code>https://</code> 等）</td>
					<td>Windows 盘符绝对路径</td>
					<td><code>C:\</code>、<code>F:/</code></td>
			</tr>
			<tr>
					<td><code>AppData[\\/](Local|Roaming)</code></td>
					<td>Windows 用户配置目录</td>
					<td><code>AppData\Local</code></td>
			</tr>
			<tr>
					<td><code>C:[\\/]Users[\\/]</code></td>
					<td>Windows 用户目录</td>
					<td><code>C:\Users\...</code></td>
			</tr>
			<tr>
					<td><code>/home/[A-Za-z0-9_.-]+/</code></td>
					<td>Linux 家目录</td>
					<td><code>/home/user/...</code></td>
			</tr>
			<tr>
					<td><code>/Users/[A-Za-z0-9_.-]+/</code></td>
					<td>macOS 家目录</td>
					<td><code>/Users/user/...</code></td>
			</tr>
			<tr>
					<td><code>%LOCALAPPDATA%|%APPDATA%|%USERPROFILE%</code></td>
					<td>本机环境变量路径</td>
					<td><code>%USERPROFILE%</code></td>
			</tr>
	</tbody>
</table>
<p>脚本跳过二进制与图片后缀（.exe/.zip/.dll/.pyc/.png/.ico/.jpg 等）和自身；<code>--staged</code> 模式只扫暂存区（给 pre-commit 钩子用）。</p>
<p>全文如下，连同启用它的本地钩子一起（点开即可复制；只用 Python 标准库，无第三方依赖）：</p>





<div
  id="accordion-b96870f15e9de74552d633591a18b9e9"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>scripts/check_privacy.py（69 行）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="ch">#!/usr/bin/env python3</span>
</span></span><span class="line"><span class="cl"><span class="c1"># -*- coding: utf-8 -*-</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;隐私检查：扫描仓库跟踪的文件中是否残留本机绝对路径等痕迹。
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">CI（每次 push/PR）与本地 pre-commit 钩子共用；命中即退出码 1。
</span></span></span><span class="line"><span class="cl"><span class="s2">用法:
</span></span></span><span class="line"><span class="cl"><span class="s2">    python scripts/check_privacy.py            # 扫描 git 跟踪的全部文件
</span></span></span><span class="line"><span class="cl"><span class="s2">    python scripts/check_privacy.py --staged   # 只扫描暂存区（pre-commit 用）
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">_s</span> <span class="ow">in</span> <span class="p">(</span><span class="n">sys</span><span class="o">.</span><span class="n">stdout</span><span class="p">,</span> <span class="n">sys</span><span class="o">.</span><span class="n">stderr</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">_s</span><span class="o">.</span><span class="n">reconfigure</span><span class="p">(</span><span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">,</span> <span class="n">errors</span><span class="o">=</span><span class="s2">&#34;replace&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 每条：正则、说明。注意 (?!...) 负向断言避免误伤 https:// 这类正常文本。</span>
</span></span><span class="line"><span class="cl"><span class="n">PATTERNS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;(?&lt;![A-Za-z0-9])[A-Za-z]:[</span><span class="se">\\</span><span class="s2">/]&#34;</span><span class="p">),</span> <span class="s2">&#34;Windows 绝对路径（如 C:</span><span class="se">\\</span><span class="s2"> 或 F:/）&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;AppData[</span><span class="se">\\</span><span class="s2">/](?:Local|Roaming)&#34;</span><span class="p">),</span> <span class="s2">&#34;Windows 用户配置目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;C:[</span><span class="se">\\</span><span class="s2">/]Users[</span><span class="se">\\</span><span class="s2">/]&#34;</span><span class="p">),</span> <span class="s2">&#34;Windows 用户目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;/home/[A-Za-z0-9_.-]+/&#34;</span><span class="p">),</span> <span class="s2">&#34;Linux 家目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;/Users/[A-Za-z0-9_.-]+/&#34;</span><span class="p">),</span> <span class="s2">&#34;macOS 家目录&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;%LOCALAPPDATA%|%APPDATA%|%USERPROFILE%&#34;</span><span class="p">),</span> <span class="s2">&#34;本机环境变量路径&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">SKIP_FILES</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;scripts/check_privacy.py&#34;</span><span class="p">}</span>          <span class="c1"># 本文件含规则字面量，跳过自身</span>
</span></span><span class="line"><span class="cl"><span class="n">SKIP_SUFFIX</span> <span class="o">=</span> <span class="p">(</span><span class="s2">&#34;.ico&#34;</span><span class="p">,</span> <span class="s2">&#34;.png&#34;</span><span class="p">,</span> <span class="s2">&#34;.jpg&#34;</span><span class="p">,</span> <span class="s2">&#34;.zip&#34;</span><span class="p">,</span> <span class="s2">&#34;.exe&#34;</span><span class="p">,</span> <span class="s2">&#34;.dll&#34;</span><span class="p">,</span> <span class="s2">&#34;.pyc&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">tracked_files</span><span class="p">(</span><span class="n">staged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">staged</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">out</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;diff&#34;</span><span class="p">,</span> <span class="s2">&#34;--cached&#34;</span><span class="p">,</span> <span class="s2">&#34;--name-only&#34;</span><span class="p">,</span> <span class="s2">&#34;--diff-filter=ACM&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">                             <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">out</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">strip</span><span class="p">()]</span>
</span></span><span class="line"><span class="cl">    <span class="n">out</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">run</span><span class="p">([</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;ls-files&#34;</span><span class="p">],</span> <span class="n">capture_output</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">text</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">check</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span><span class="o">.</span><span class="n">stdout</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">[</span><span class="n">p</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">out</span><span class="o">.</span><span class="n">splitlines</span><span class="p">()</span> <span class="k">if</span> <span class="n">p</span><span class="o">.</span><span class="n">strip</span><span class="p">()]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">main</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">    <span class="n">staged</span> <span class="o">=</span> <span class="s2">&#34;--staged&#34;</span> <span class="ow">in</span> <span class="n">sys</span><span class="o">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">:]</span>
</span></span><span class="line"><span class="cl">    <span class="n">hits</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">path</span> <span class="ow">in</span> <span class="n">tracked_files</span><span class="p">(</span><span class="n">staged</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">path</span> <span class="ow">in</span> <span class="n">SKIP_FILES</span> <span class="ow">or</span> <span class="n">path</span><span class="o">.</span><span class="n">endswith</span><span class="p">(</span><span class="n">SKIP_SUFFIX</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">with</span> <span class="nb">open</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">encoding</span><span class="o">=</span><span class="s2">&#34;utf-8&#34;</span><span class="p">,</span> <span class="n">errors</span><span class="o">=</span><span class="s2">&#34;ignore&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">for</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">line</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">f</span><span class="p">,</span> <span class="mi">1</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                    <span class="k">for</span> <span class="n">rx</span><span class="p">,</span> <span class="n">desc</span> <span class="ow">in</span> <span class="n">PATTERNS</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                        <span class="n">m</span> <span class="o">=</span> <span class="n">rx</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">line</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                        <span class="k">if</span> <span class="n">m</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                            <span class="n">hits</span><span class="o">.</span><span class="n">append</span><span class="p">((</span><span class="n">path</span><span class="p">,</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">desc</span><span class="p">,</span> <span class="n">m</span><span class="o">.</span><span class="n">group</span><span class="p">(</span><span class="mi">0</span><span class="p">)))</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="p">(</span><span class="ne">OSError</span><span class="p">,</span> <span class="ne">UnicodeError</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">hits</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;✘ 检测到可能的隐私痕迹（本机路径），请脱敏后再提交：&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">path</span><span class="p">,</span> <span class="n">lineno</span><span class="p">,</span> <span class="n">desc</span><span class="p">,</span> <span class="n">frag</span> <span class="ow">in</span> <span class="n">hits</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;  </span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">lineno</span><span class="si">}</span><span class="s2">  [</span><span class="si">{</span><span class="n">desc</span><span class="si">}</span><span class="s2">] 命中：</span><span class="si">{</span><span class="n">frag</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n</span><span class="s2">提示：改用相对路径或占位符（如『项目根目录』『示例目录A』）。&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;✓ 隐私检查通过：未发现本机绝对路径痕迹&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">sys</span><span class="o">.</span><span class="n">exit</span><span class="p">(</span><span class="n">main</span><span class="p">())</span></span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>scripts/hooks/pre-commit（本地拦截，仓库根目录执行一次 git config core.hooksPath scripts/hooks 即生效）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="cp">#!/bin/sh
</span></span></span><span class="line"><span class="cl"><span class="c1"># 本地 pre-commit 钩子：提交前扫描暂存区是否含本机路径等隐私痕迹。</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 启用方式（仓库根目录执行一次）：</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   git config core.hooksPath scripts/hooks</span>
</span></span><span class="line"><span class="cl">python scripts/check_privacy.py --staged <span class="o">||</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;提交被拦截：请先处理上述隐私痕迹（或临时用 git commit --no-verify 跳过）。&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span></span></span></code></pre></div></div>

  </div>
</details>

</div>

<style>
  #accordion-b96870f15e9de74552d633591a18b9e9 > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-b96870f15e9de74552d633591a18b9e9 > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-b96870f15e9de74552d633591a18b9e9 details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-b96870f15e9de74552d633591a18b9e9 details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-b96870f15e9de74552d633591a18b9e9");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>


<p>上线第一天它就立了功：本地首跑立即命中 <code>README.md:67</code>——README 里演示命令行用法写的示例路径（形如 <code>D:\某目录\...</code>）被判为 Windows 绝对路径。演示路径虽是占位用途，但规则不留模糊地带：把示例改成无盘符的「目录A...」占位，提交通过。</p>
<p>门禁分两处挂上：</p>
<ul>
<li><strong>CI</strong>：workflow 里在语法检查之后、内核一致性验收之前加一步 <code>python scripts/check_privacy.py</code>，每次 push 都跑，命中即失败；</li>
<li><strong>本地</strong>：<code>scripts/hooks/pre-commit</code> 里跑 <code>--staged</code> 模式；在仓库根目录执行一次 <code>git config core.hooksPath scripts/hooks</code> 即生效。</li>
</ul>

<h3 class="relative group">5. 弯路：强推之后，旧 commit 依然可达
    <div id="5-弯路强推之后旧-commit-依然可达" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#5-%e5%bc%af%e8%b7%af%e5%bc%ba%e6%8e%a8%e4%b9%8b%e5%90%8e%e6%97%a7-commit-%e4%be%9d%e7%84%b6%e5%8f%af%e8%be%be" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>一切就绪后 <code>git push --force origin main</code> 与 <code>git push --force origin --tags</code> 强推。GitHub 侧确认远端 main 已换成干净历史（最新提交即门禁提交，状态 G），Release 完好。然后探测三个旧 SHA，结果如下：</p>
<table>
	<thead>
			<tr>
					<th>探测对象</th>
					<th>强推之后</th>
					<th>删库重建之后</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>旧提交 SHA-1（含路径）</td>
					<td>返回完整 SHA，<strong>仍可访问</strong></td>
					<td>HTTP 422 <code>No commit found</code></td>
			</tr>
			<tr>
					<td>旧提交 SHA-2</td>
					<td>仍可访问</td>
					<td>HTTP 422</td>
			</tr>
			<tr>
					<td>旧提交 SHA-3</td>
					<td>仍可访问</td>
					<td>HTTP 422</td>
			</tr>
	</tbody>
</table>
<p>这是本篇最值钱的一点：<strong>改写历史 ≠ 清除历史</strong>。force push 让旧对象在引用层面&quot;不可达&rdquo;，但公开平台会保留不可达对象一段时间（作为旧链接缓存），不会立即物理删除；被分享出去的旧 commit 链接，打开仍能看到那行路径和完整 diff。</p>

<h3 class="relative group">6. 终局：删库重建
    <div id="6-终局删库重建" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#6-%e7%bb%88%e5%b1%80%e5%88%a0%e5%ba%93%e9%87%8d%e5%bb%ba" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>接受现状不符合&quot;彻底清除&quot;的目标（旧链接还挂着路径），于是选了最彻底也最省心的方案：<strong>删除仓库，同名重建</strong>。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gh repo delete &lt;账号&gt;/&lt;仓库&gt; --yes                <span class="c1"># 删除</span>
</span></span><span class="line"><span class="cl">gh api repos/&lt;账号&gt;/&lt;仓库&gt;                        <span class="c1"># 确认 404</span>
</span></span><span class="line"><span class="cl">gh repo create &lt;账号&gt;/&lt;仓库&gt; --public --source . --remote origin --push
</span></span><span class="line"><span class="cl">git push origin --tags                            <span class="c1"># 触发 tag 构建</span></span></span></code></pre></div></div>
<p>执行要点：删除后先用 API 确认返回 404 再重建（同名）；重建后推 main 与两个 tag，同一刻三个 CI run（main 提交 + 两个 tag）排队构建，Release 由 tag 触发自动重建；顺手补上仓库描述与 9 个 topics。</p>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>判定&quot;解决&quot;的标准是<strong>旧链接彻底失效</strong>：</p>
<ul>
<li>删库重建后，三个旧 SHA 探测全部返回 HTTP 422 <code>No commit found for SHA: ...</code>（GitHub REST 对不存在的提交返回 422 而非 404）；当年被分享出去的那个链接也失效了；</li>
<li>新仓库为 PUBLIC，描述与 9 个 topics 就位；</li>
<li>3 个 CI run（main + 两个 tag）全部 success；两个 Release 由 tag 触发重建完毕，资产齐全（可执行程序、GUI 压缩包、网页版、SHA256SUMS 校验文件）；</li>
<li>远端 <code>web/build_web.py</code> 第 6 行确认为「项目根目录」，全历史零泄漏；</li>
<li>门禁提交在远端日志显示 <code>G</code>（Verified）。</li>
</ul>
<p>怎么判断&quot;没解决&quot;：复制旧 commit 链接直接打开，还能看到 diff 和那行路径——说明不可达对象还活着。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>时机</th>
					<th>动作</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>提交前（成本最低）</td>
					<td>仓库放 <code>check_privacy.py</code>；CI 加一步扫描；本地 <code>git config core.hooksPath scripts/hooks</code> 启用 pre-commit；示例路径一律用无盘符占位</td>
			</tr>
			<tr>
					<td>提交前（另一视角）</td>
					<td>记住&quot;历史即公开面&quot;：任何一次提交的增删行都会永久展示，删除一行泄漏 = 把泄漏写进 diff</td>
			</tr>
			<tr>
					<td>发现历史泄漏后</td>
					<td>先 <code>git bundle create --all</code> 备份全历史（备份内仍含旧路径，勿外推）</td>
			</tr>
			<tr>
					<td>清理时</td>
					<td>filter-repo <code>--replace-text</code>（替换表长串优先）→ 空提交自动清 → 全历史重扫 → <code>rebase --root</code> 重签全部提交与 tag</td>
			</tr>
			<tr>
					<td>强推之后</td>
					<td>用 <code>gh api repos/&lt;账号&gt;/&lt;仓库&gt;/commits/&lt;旧SHA&gt;</code> 探测：仍返回 SHA 就说明缓存还活着</td>
			</tr>
			<tr>
					<td>要彻底清除</td>
					<td>删库重建（同名）：删除 → 404 确认 → 重建 → 推 main/tags → CI 重建 Release → 旧 SHA 全部 422</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>事后清理这一趟，成本不在改写本身（秒级），而在连锁动作：重签全部提交、重建 tag、删库重建、等 CI 重打 Release、一个个探测旧 SHA。而这一切的起因，只是提交时多写的一行注释，加一次只扫工作区、没扫历史的&quot;复查&quot;。</p>
<p>对开源仓库来说，历史就是公开面，任何一次提交都可能在未来某个时刻被人翻开。比事后清理便宜得多的做法，是让机器在提交发生前拦一道：一条正则、一个 CI 步骤、一个 pre-commit 钩子。最后再强调一遍备份的事：改写前的备份里还躺着那行旧路径，别把备份当成普通仓库推上去，确认干净稳定后记得删掉。</p>
]]></content:encoded>
    </item>
    <item>
      <title>512MB 小内存云主机整机失联：自动更新触发 OOM，打死了网络栈</title>
      <link>https://slop.lun.sh/small-vps-oom-kills-network/</link>
      <pubDate>Fri, 18 Sep 2026 20:10:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/small-vps-oom-kills-network/</guid>
      <category>OOM</category>
      <category>systemd-networkd</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一台 512MB 的 Ubuntu 云主机（跑 sing-box 代理）某天突然&quot;整机失联&quot;：云控制台显示实例正在运行、静态 IP 没变，SSH、代理端口、云厂商的浏览器终端却全都不通，只有重启才恢复。一天之内，两台同配置的机器先后中招。根因不是网卡、不是运营商、也不是云厂商，而是无人值守自动更新（unattended-upgrades）把内存打满，全局 OOM 连带把负责网络配置的 systemd-networkd 拖进 Failed，默认路由与 DNS 随后消失——机器&quot;活着但没网&quot;。适用环境：Ubuntu 22.04 LTS（内核 6.8.0-1063-aws）、systemd-networkd、512MB 云主机，时间 2026-09-09 至 09-10。</p>
</blockquote>
<h2 class="relative group">一、症状与第一手数据
    <div id="一症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两台机在不到一天里先后病倒。本文主角（下称 A 机）于 2026-09-10 早晨 08:00（CST）前后失联；另一台同配置机器（下称 B 机）在约 19 小时前——09-09 06:18 UTC——以同样的链条先倒下，失联约 18 小时才被发现。</p>
<p>A 机失联时的观测数据：</p>
<ul>
<li>云控制台：实例状态&quot;正在运行&quot;，静态 IP 未变；实例历史无任何停止/回收动作 → 云侧没动过它。</li>
<li>外部探测：SSH（非标准端口）与代理入站端口全部 TCP 超时、无 SYN-ACK（不是拒绝），ICMP 不通。</li>
<li>浏览器终端：报 <code>UPSTREAM_ERROR [515]</code>——云厂商会话管理 agent（SSM）连不上元数据服务 <code>169.254.169.254</code>，控制台通道本身也断了。</li>
<li>自建监控 agent 主动出站上报心跳，最后一次心跳 23:55:24 UTC。入站与出站同时中断 → 故障在整机/网络层，不在 sing-box 进程层。</li>
<li>对照：其余地域节点全部正常，云厂商状态页无对应事件 → 排除全局网络故障与区域事故。</li>
</ul>
<p>排查期间还踩了个坑：本机经代理/TUN 测端口会&quot;乐观握手&quot;——本地应用立刻拿到 SYN-ACK，实际转发到上游才算成功，第一轮误报&quot;已恢复&quot;。改用两台异地干净观测点直连复测，才确认 09:13 控制台重启后 sing-box 09:13:52 自动拉起、监控 09:13:55 恢复上报，失联时长约 74 分钟。</p>
<p>重启后从前一次启动的 journal 里挖出完整的故障链日志：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># A 机（最后一段崩溃现场）</span>
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:07:33 systemd-networkd<span class="o">[</span>342<span class="o">]</span>: ens5: Could not <span class="nb">set</span> route: Connection timed out
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:07:33 systemd-networkd<span class="o">[</span>342<span class="o">]</span>: ens5: Failed
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:08:13 kernel: oom-kill: ... global_oom, <span class="nv">task_memcg</span><span class="o">=</span>/system.slice/apt-daily-upgrade.service, <span class="nv">task</span><span class="o">=</span>unattended-upgr
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:08:13 kernel: Out of memory: Killed process <span class="m">14467</span> <span class="o">(</span>unattended-upgr<span class="o">)</span> total-vm:469680kB, anon-rss:150328kB ... oom_score_adj:0
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:08:38 kernel: Out of memory: Killed process <span class="m">14650</span> <span class="o">(</span>unattended-upgr<span class="o">)</span> anon-rss:163744kB
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 07:12:15 kernel: agent invoked oom-killer ... Out of memory: Killed process <span class="o">(</span>unattended-upgr<span class="o">)</span>
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 08:00:09 sing-box: ERROR router: missing default interface
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 08:00:23 agent: lookup &lt;监控域名&gt; on 127.0.0.53:53: server misbehaving
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 08:04:51 ssm-agent: 169.254.169.254 connect: network is unreachable
</span></span><span class="line"><span class="cl">Sep <span class="m">10</span> 09:13:36 systemd<span class="o">[</span>1<span class="o">]</span>: systemd-networkd.service: Deactivated successfully.  ← 重启</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># B 机（早 19 小时的同一套日志）</span>
</span></span><span class="line"><span class="cl">Sep <span class="m">09</span> 06:18:11 systemd-networkd<span class="o">[</span>352<span class="o">]</span>: eth0: Could not <span class="nb">set</span> route: Connection timed out
</span></span><span class="line"><span class="cl">Sep <span class="m">09</span> 06:18:11 systemd-networkd<span class="o">[</span>352<span class="o">]</span>: eth0: Failed
</span></span><span class="line"><span class="cl">Sep <span class="m">09</span> 06:18:11 kernel: Out of memory: Killed process <span class="m">51149</span> <span class="o">(</span>localedef<span class="o">)</span> total-vm:127980kB, anon-rss:115752kB</span></span></code></pre></div></div>
<p>两机同源：都是 512MB 内存、<code>Swap: 0</code>、日常 <code>apt-daily-upgrade</code>（unattended-upgrades）触发全局 OOM。B 机先倒、无人察觉；A 机随后复现，才把问题翻出来。</p>
<table>
	<thead>
			<tr>
					<th>时间（UTC）</th>
					<th>日志/事件</th>
					<th>含义</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>06:52</td>
					<td><code>apt-daily-upgrade.timer</code> 启动</td>
					<td>每日升级开始了</td>
			</tr>
			<tr>
					<td>07:07:33</td>
					<td>networkd <code>Could not set route: Connection timed out</code> → <code>Failed</code></td>
					<td>内存耗尽、内核极端迟滞，设置 DHCP 路由超时，网络配置服务死亡</td>
			</tr>
			<tr>
					<td>07:08:13 / 07:08:38 / 07:12:15</td>
					<td><code>Out of memory: Killed process (unattended-upgr)</code></td>
					<td>全局 OOM，三次杀掉升级进程；apt 被杀后又被拉起、再次被杀</td>
			</tr>
			<tr>
					<td>08:00:09</td>
					<td>sing-box <code>missing default interface</code></td>
					<td>networkd 死后无人续租约，默认路由消失</td>
			</tr>
			<tr>
					<td>08:00:23</td>
					<td>监控 agent DNS 查询失败</td>
					<td>DNS 失效</td>
			</tr>
			<tr>
					<td>08:04:51</td>
					<td>SSM agent 连元数据服务失败</td>
					<td>控制台终端 515 的直接原因</td>
			</tr>
			<tr>
					<td>09:13</td>
					<td>控制台重启</td>
					<td>sing-box 09:13:52 拉起、监控 09:13:55 恢复上报</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">二、根因
    <div id="二根因" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>链条是这样走的：</p>
<ol>
<li><strong>内存被打满</strong>。512MB 实例、无 swap，日常占用已接近一半（journald 约 28MB、multipathd 约 27MB、sing-box 约 25MB、snapd 约 21MB，加 buff/cache 可用只剩约 200MB）。<code>unattended-upgrades</code> 升级时再吃 150MB+，内存耗尽。</li>
<li><strong>负责网络的服务先被拖死</strong>。内存耗尽后内核迟滞，<code>systemd-networkd</code> 执行设置路由的操作超时，报 <code>Could not set route: Connection timed out</code> 后进入 <code>Failed</code> 状态——它一死，DHCP 租约不再续期。</li>
<li><strong>OOM killer 动手，但为时已晚</strong>。全局 OOM 触发，被杀的是 <code>unattended-upgr</code>（150MB）和 <code>localedef</code>（115MB）这类升级进程；此时 networkd 已死，路由/DNS 只是时间问题。约 53 分钟后默认路由消失（A 机 08:00:09），DNS 随后失效。</li>
<li><strong>机器&quot;活着但没网&quot;</strong>。内核还在跑、进程还在、云面板显示 running，但没有任何网络路径：出站不行、入站更不行，SSH、代理、控制台终端全部不可达。没有 panic、没有 oops、连 hung task 都没有——journal 里除了内核那几行 OOM 和 networkd 两行 <code>Failed</code>，再无痕迹。常规搜 <code>panic/error</code> 什么也扫不出来。</li>
</ol>
<p>两个值得注意的点。其一，OOM killer 实际杀掉的是升级进程，networkd 是&quot;超时自杀&quot;；但加固前它的 <code>OOMScoreAdjust=0</code>、<code>Restart=on-failure</code>，在全局 OOM 里一样是零保护的候选，这次没被挑中纯属运气——这决定了下面的加固方向。其二，这种故障不留明显崩溃痕迹，B 机失联 18 小时没有任何人发现，正是因为面板显示正常、日志&quot;看起来很干净&quot;。</p>

<h2 class="relative group">三、处置过程（含走过的弯路）
    <div id="三处置过程含走过的弯路" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e8%b5%b0%e8%bf%87%e7%9a%84%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>弯路一：本机测端口误报恢复。</strong> 本机经 TUN/代理测目标端口，本地应用立刻拿到 SYN-ACK，第一轮三个端口全部&quot;OPEN&quot;；干净外部源直连才暴露真相：全部超时。结论必须以非本机的观测点为准。</p>
<p><strong>弯路二：自动重启被误关又恢复。</strong> 处置中一度把 <code>50unattended-upgrades</code> 的 <code>Automatic-Reboot</code> 从 <code>true</code> 改成 <code>false</code>，理由&quot;去掉多余的重启&quot;。随后确认一个名实问题：机器每周日凌晨的重启<strong>不是 cron</strong>——手动重启 cron 早在几天前加固时已删除（末次触发 09-06）；真正在做凌晨自动重启的是 unattended-upgrades 的 <code>Automatic-Reboot-Time &quot;02:00&quot;</code>（另一台机 08-20、08-22 两次 02:00 重启即它所致）。而站主平时不会定期登录机器，若关掉这个开关，装了内核更新也永不生效——最终改回 <code>true</code> 并以 <code>apt-config dump</code> 验证生效。这里想对读者说明取舍：关掉 <code>Automatic-Reboot</code> 的代价，就是内核安全更新要等下次人工重启才生效；无定期登录习惯的机器不建议关。</p>
<p><strong>上机复核：另一套并行加固已生效。</strong> 出手前先复核现状，发现当天早些时候已经有另一批加固配置落在两台机器上（不是本次操作加的）：apt 服务的内存 cgroup 上限（<code>MemoryMax=250M</code> / <code>MemoryHigh=200M</code>）、networkd 的 <code>OOMScoreAdjust=-1000</code> + <code>Restart=always</code>、<code>multipathd</code> 停用、以及一个 <code>net-watchdog</code> 路由看门狗。逐项验证确实生效，且与本次新增的 swap 不冲突——fstab 里 <code>/swapfile</code> 只有一条、无重复挂载，<code>findmnt --verify</code> 通过。这套思路比&quot;只加 swap&quot;更对：<strong>卡住 OOM 源头</strong>（apt 内存升到 250M 就在自己的 cgroup 内被杀，整机不受影响），而不是等它打死网络栈再救。</p>
<p><strong>本次补做的处置</strong>：</p>
<ol>
<li><strong>2GB swapfile</strong>：<code>fallocate -l 2G</code> → <code>mkswap</code> → <code>swapon</code>，写进 <code>/etc/fstab</code>（<code>/swapfile none swap sw 0 0</code>）。作用：吸收突发内存压力，让 OOM 尽量不发生。注意 swap 只是缓冲不是解药——OOM 一旦真的发生，netfilter 之外的任何进程都可能被随机挑中，网络栈仍可能中招。</li>
<li><strong>清理纯代理机用不上的定时器</strong>：禁用 <code>motd-news.timer</code>、<code>update-notifier-download.timer</code>、<code>update-notifier-motd.timer</code>（每日拉资讯/弹更新通知，代理机上无用途，省网络与内存）；保留 <code>apt-daily</code>、<code>apt-daily-upgrade</code>（安全更新）、<code>fstrim</code>、<code>e2scrub_all</code>、<code>logrotate</code>、<code>dpkg-db-backup</code>、<code>net-watchdog</code>。顺手停用 <code>multipathd</code>（无多路径存储，白占约 27MB）。</li>
<li><strong>补上离线告警</strong>：自建监控的离线告警此前未开，B 机失联 18 小时无人知晓。开启后同类失联约 3 分钟（grace 180 秒）内推送到消息通道。</li>
<li><strong>规格决策</strong>：站主否决升配（加内存每月约 +2 美元，对纯代理机不划算），以&quot;swap + 内存 cgroup 上限 + OOM 保护 + 路由看门狗&quot;组合替代。</li>
</ol>
<p>看门狗逻辑（每 2 分钟，<code>OnBootSec=3min</code>、<code>OnUnitActiveSec=2min</code>、<code>AccuracySec=15s</code>）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="nv">LOG</span><span class="o">=</span>/var/log/net-watchdog.log
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! ip -4 route show default <span class="p">|</span> grep -q .<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> NO-DEFAULT-ROUTE -&gt; restarting systemd-networkd&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  systemctl restart systemd-networkd
</span></span><span class="line"><span class="cl">  sleep <span class="m">10</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> ip -4 route show default <span class="p">|</span> grep -q .<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> recovered&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> STILL-DOWN&#34;</span> &gt;&gt; <span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div>
<p>它只治网络栈这一层，机器彻底卡死时它自己也跑不起来——所以它是兜底，不是主力；主力仍是掐住 OOM 源头。</p>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>加固后用可观测指标逐项复核（两台机同批）；另外注意 <code>OOMScoreAdjust</code> 对已运行的进程要下次启动才生效，核实取值即可，不必为此强行重启 networkd。</p>
<table>
	<thead>
			<tr>
					<th>检查项</th>
					<th>方法</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>内存上限生效</td>
					<td><code>systemctl show apt-daily-upgrade.service -p MemoryMax</code></td>
					<td>262144000（250M）✅</td>
			</tr>
			<tr>
					<td>networkd 保护</td>
					<td><code>systemctl show systemd-networkd -p OOMScoreAdjust -p Restart</code></td>
					<td><code>-1000</code> / <code>always</code> ✅</td>
			</tr>
			<tr>
					<td>看门狗运行且未误触发</td>
					<td><code>systemctl list-timers net-watchdog.timer</code>；<code>/var/log/net-watchdog.log</code></td>
					<td>enabled + active，每 2 分钟一次；日志为空 = 默认路由从未丢过 ✅</td>
			</tr>
			<tr>
					<td>swap 持久化</td>
					<td><code>swapon --show</code>、<code>grep swap /etc/fstab</code>、<code>findmnt --verify</code></td>
					<td><code>/swapfile</code> 2G 生效，fstab 单条目无重复，校验通过；已在吸收压力（一台 8MB、一台 35MB 在用）✅</td>
			</tr>
			<tr>
					<td>服务端口</td>
					<td>异地干净源 TCP 探测 + <code>openssl s_client</code> REALITY 握手</td>
					<td>全通；TLS1.3、<code>Verify return code: 0 (ok)</code> ✅</td>
			</tr>
			<tr>
					<td>监控心跳</td>
					<td>监控面板各节点最后上报</td>
					<td>全部 5 分钟内（含故障过的两台）✅</td>
			</tr>
			<tr>
					<td>自动重启配置</td>
					<td><code>apt-config dump | grep Automatic-Reboot</code></td>
					<td><code>true</code>，与站主决策一致 ✅</td>
			</tr>
			<tr>
					<td>保留的定时器</td>
					<td><code>systemctl list-timers</code></td>
					<td><code>apt-daily*</code>/<code>fstrim</code>/<code>logrotate</code>/<code>dpkg-db-backup</code>/<code>net-watchdog</code> 全部 enabled ✅</td>
			</tr>
	</tbody>
</table>
<p>实机后的观察：swap 建立后已开始承接压力（一台 8MB、一台 35MB），说明 512MB 上日常内存就是紧的。遗留的边界：apt 被限制在 250M 后，个别大升级可能因内存不足<strong>失败但机器没事</strong>——失败目前没有独立告警（监控只报在线/离线），需要的话可加一个&quot;升级任务失败&quot;探针。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>第一步永远是看内核日志里有没有 OOM</strong>：<code>journalctl -k --since &quot;1 day ago&quot; | grep -iE &quot;Out of memory|Killed process&quot;</code>。整机&quot;活着但没网&quot;时，这是最快的定位动作。</li>
<li>面板 Running + 静态 IP 未变 + 入站出站同时断（监控 agent 的主动上报也停）= 先怀疑整机资源/网络栈层，别急着找运营商。</li>
<li>小内存机器给 apt 体系套内存 cgroup 上限：<code>apt-daily</code> 与 <code>apt-daily-upgrade</code> 各建 drop-in，<code>MemoryMax=250M</code>、<code>MemoryHigh=200M</code>。</li>
<li>给网络服务保命：<code>systemd-networkd</code> drop-in 设 <code>OOMScoreAdjust=-1000</code>、<code>Restart=always</code>、<code>RestartSec=10s</code>。</li>
<li>路由看门狗（上文的 shell 脚本）作为兜底；记住它只治网络栈，机器彻底卡死时无效。</li>
<li>加 swap 只解决&quot;尽量不发生 OOM&quot;，不解决&quot;OOM 发生时谁被挑中&quot;；两者定位不同，别混为一谈。</li>
<li>自动重启开关的取舍：无定期登录习惯就保留 <code>Automatic-Reboot &quot;true&quot;</code>，否则内核更新永不生效。</li>
<li>本机经代理/TUN 测端口会乐观握手，端口存活判断一律用异地干净源。</li>
<li>这类故障不留崩溃痕迹，离线告警必须开——监控不告警等于没监控。</li>
</ul>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>B 机先倒下 19 小时无人发现，A 机再倒才被重视——两台的差别只是先后，不是运气。回头想，&ldquo;机器活着但没网&quot;大概是运维里最容易被误判为运营商/云厂商问题的一类故障：面板一切正常，探测全部超时，谁都会先怀疑链路。实际上只要先花十秒钟看一眼内核日志里有没有 <code>Out of memory</code>，这整场排查就结束了。小内存机器上，自动更新这种&quot;每天都在跑、从来不看结果&quot;的任务，往往是隐藏的定时炸弹；给它套上 cgroup 上限、给网络服务戴上护具，再留一个看门狗兜底，这套组合拳对 512MB 级别的云主机都适用。</p>
]]></content:encoded>
    </item>
    <item>
      <title>反复掉线一分钟的 WordPress 站：CDN 其实从未缓存过 HTML</title>
      <link>https://slop.lun.sh/cdn-html-cache-wordpress-outage/</link>
      <pubDate>Fri, 18 Sep 2026 20:05:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/cdn-html-cache-wordpress-outage/</guid>
      <category>WordPress</category>
      <category>CDN</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一个服务端渲染的 WordPress 站每隔几天就出现一次约 1 分钟不可用：访客和监控探针同时超时或 504，随后自行恢复，发生频率与外部抓取器、扫描器的活动正相关。本文记录这次排查。适用环境：WordPress + Hestia 控制面板（nginx→Apache 反向代理栈）+ php-fpm 8.3 + 前置 Bunny CDN，2026-09 实测。结论先行：根因不是网络、不是重启，而是这条链路上没有任何一层 HTML 缓存——CDN 的 Smart Cache 设计上永不缓存 <code>text/html</code>，源站又对每个页面发 <code>no-cache</code>，于是每次匿名访问都是完整回源跑一遍 PHP。</p>
</blockquote>
<h2 class="relative group">一、症状与第一手数据
    <div id="一症状与第一手数据" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%97%87%e7%8a%b6%e4%b8%8e%e7%ac%ac%e4%b8%80%e6%89%8b%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>监控服务在最近 5 天报告了 3 起约 1 分钟的掉线（表 1）。每次都没有明确的错误码：多数探测点是超时，个别探到源站 504；站点没有重启、没有 OOM、没有网络变更，掉线是&quot;自己好的&quot;。频率上与外部请求活动强相关。</p>
<table>
	<thead>
			<tr>
					<th>掉线时刻（UTC）</th>
					<th>源站现场</th>
					<th>触发者</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>09-14 10:53</td>
					<td>25 s 内 250 个请求（峰值 18/s）扫不存在的主题 CSS；每个命中都返回 46.6 KB 的主题 404 页（= 一次完整 PHP 渲染）</td>
					<td>伪造官方优化器 UA 的扫描器</td>
			</tr>
			<tr>
					<td>09-17 02:09</td>
					<td>探针收到 10 条 503（约 3 KB 错误页）；插件目录与升级备份目录落盘时间吻合，伴随更新回环自检与 wp-cron 并发</td>
					<td>WordPress 插件自动更新（维护模式）</td>
			</tr>
			<tr>
					<td>09-17 18:02</td>
					<td>100 s 内 357 个请求、单秒 61 个到达；同一篇文章被重复抓 88 次；源站出现 2 条 504 与 Apache <code>AH01075 ... (polling)</code>（FCGI 派发超时，即请求已排队超过 30 s）</td>
					<td>一个家宽 IP 的抓取器（UA 自报）</td>
			</tr>
	</tbody>
</table>
<p>判据来自监控探针的请求计数：探针在源站日志里的基数是每分钟 3–4 次，掉线分钟翻到 9–12 次——监控判定 down 后会对每个探测地点重试，请求量放大就是掉线指纹。用这一条从源站日志反推出全部掉线时刻，与监控 CSV 完全对齐，还为历史补齐了一串同型记录（每隔 2–4 天一次的整点后 :09 分钟 503 风暴）。</p>
<p>三个现场都指向同一个事实：<strong>php-fpm 池被突发请求打满</strong>。16 个 worker 的池子被 61 并发、250 个 404、或插件更新风暴任一撑爆，与站点共用同一池的监控探针立即跟着失败。</p>
<p>但&quot;被打满&quot;只是最后一环，真正要回答的是：<strong>为什么区区几十个并发就能打满整站？</strong> 对响应头做一次&quot;判缓存真伪&quot;的检查，答案立刻浮出来。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-http" data-lang="http"><span class="line"><span class="cl"><span class="err"># 改造前，公网连打 5 次匿名首页/文章，每次都一样
</span></span></span><span class="line"><span class="cl"><span class="kr">HTTP</span><span class="o">/</span><span class="m">2</span> <span class="m">200</span>
</span></span><span class="line"><span class="cl"><span class="n">content-type</span><span class="o">:</span> <span class="l">text/html; charset=UTF-8</span>
</span></span><span class="line"><span class="cl"><span class="n">cache-control</span><span class="o">:</span> <span class="l">no-cache</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-cache</span><span class="o">:</span> <span class="l">MISS</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-cachedat</span><span class="o">:</span> <span class="l">09/17/2026 23:43:10   ← 等于请求当刻</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-requestpullsuccess</span><span class="o">:</span> <span class="l">True</span>
</span></span><span class="line"><span class="cl"><span class="n">cdn-requestpullcode</span><span class="o">:</span> <span class="l">200</span></span></span></code></pre></div></div>
<p><code>cdn-cache: MISS</code> + <code>cdn-cachedat</code> 等于请求当刻 + 每次都 <code>cdn-requestpullsuccess: True</code>，这是一个&quot;每次都现拉&quot;的指纹。对照验证：同一批请求同步出现在源站 Apache 访问日志里——CDN 每收到一次页面请求，就回源运行一整次 PHP。</p>
<p>同一时刻的静态资源是完全不同的待遇：</p>
<table>
	<thead>
			<tr>
					<th>请求类型</th>
					<th>改造前 <code>cdn-cache</code></th>
					<th>源站行为</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>首页 / 文章（text/html）</td>
					<td>全部 MISS</td>
					<td>每次都回源，完整 PHP 渲染</td>
			</tr>
			<tr>
					<td>css / js / 图片</td>
					<td>HIT</td>
					<td>边缘直接返回，源站零请求</td>
			</tr>
	</tbody>
</table>
<p>所以准确的说法不是&quot;CDN 没工作&quot;——静态资源它是干活的；是 <strong>HTML 流量从没被缓存过，一直是原样转给源站的</strong>。</p>

<h2 class="relative group">二、根因
    <div id="二根因" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e6%a0%b9%e5%9b%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把链路一层层看过去，结论是：<strong>三层都没有 HTML 缓存</strong>。</p>
<table>
	<thead>
			<tr>
					<th>层</th>
					<th>改造前实际状态</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>WordPress 页面缓存</td>
					<td><code>WP_CACHE=true</code> 但 <code>wp-content/advanced-cache.php</code> 不存在——旧缓存插件卸载后常量空转，页面缓存根本没生效</td>
			</tr>
			<tr>
					<td>源站 nginx</td>
					<td>反向代理用的是默认模板，vhost 里没有 <code>fastcgi_cache</code>，microcache 未按域开启</td>
			</tr>
			<tr>
					<td>CDN 边缘</td>
					<td>Smart Cache 开着；Bunny 文档明确：<code>text/html</code>、<code>application/json</code>、<code>application/xml</code> 三种 MIME <strong>永不缓存，无论扩展名</strong></td>
			</tr>
	</tbody>
</table>
<p>第三层的机制值得展开。Bunny 的 Smart Cache 默认只缓存白名单里的静态扩展名（图片/字体/文档/代码），把 HTML 这类当作&quot;动态内容&quot;无条件放行回源，其设计理由是&quot;防止把敏感的个性化内容误缓存给别人&quot;。文档给出的绕过方式只有一条：写 Edge Rule，用 Override Cache Time 动作覆盖。</p>
<p>而源站这边，WordPress 生态里有插件在给每个页面发 <code>Cache-Control: no-cache</code>（卸载插件留下的持久化行为）。于是双重叠加：</p>
<ul>
<li>Smart Cache 说：<code>text/html</code> 永不缓存；</li>
<li>源站说：这个响应不许缓存；</li>
<li>连&quot;用 Edge Rule 强制缓存 N 分钟&quot;也被第二条否决——Bunny 不为标了 <code>no-cache</code> 的响应建缓存。</li>
</ul>
<p>结果就是：CDN 提供了一整套&quot;带宽能力&quot;（TLS/HTTP2、DDoS 吸收、静态资源缓存、图片优化），<strong>唯独对 HTML 是纯中转</strong>。这个站的每一次页面浏览，都等价于有人直接访问源站。</p>

<h2 class="relative group">三、处置过程（含走过的弯路）
    <div id="三处置过程含走过的弯路" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b%e5%90%ab%e8%b5%b0%e8%bf%87%e7%9a%84%e5%bc%af%e8%b7%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这一节比答案更值钱：Bunny 面板上的 HTML 缓存开关和 Edge Rule 强制缓存，我们实测都走不通，而且有一个开关比不开更糟。</p>

<h3 class="relative group">弯路 1：面板的&quot;静态 HTML&quot;开关把全站变成 BYPASS
    <div id="弯路-1面板的静态-html开关把全站变成-bypass" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bc%af%e8%b7%af-1%e9%9d%a2%e6%9d%bf%e7%9a%84%e9%9d%99%e6%80%81-html%e5%bc%80%e5%85%b3%e6%8a%8a%e5%85%a8%e7%ab%99%e5%8f%98%e6%88%90-bypass" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>先试 Bunny 官方路径的第一条：开 Optimizer 的&quot;静态 HTML&quot;开关。开启的瞬间，面板自动在拉区生成两条 Edge Rule：一条 action=OverrideCacheTime 且参数为 0（即永不缓存）、带 cookie 触发器，另一条是 BypassPermaCache，两条的 URL 触发器都是全站通配符。</p>
<p>实测（开启后连打匿名页面、带 cookie 请求、静态资源）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">edge try1   200 0.549s   cdn-cache: BYPASS   ← 不是 HIT，是显式旁路
</span></span><span class="line"><span class="cl">edge try2   200 0.080s   cdn-cache: BYPASS
</span></span><span class="line"><span class="cl">css try1    200 0.055s   cdn-cache: BYPASS</span></span></code></pre></div></div>
<p>连静态资源也从 HIT 掉成 BYPASS——<strong>这个开关把它自动生成的规则当作唯一裁定，全站绕开缓存，比不开更糟</strong>。回退后边缘静态资源恢复 HIT。</p>

<h3 class="relative group">弯路 2：Edge Rule 强制缓存时间仍然无效
    <div id="弯路-2edge-rule-强制缓存时间仍然无效" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bc%af%e8%b7%af-2edge-rule-%e5%bc%ba%e5%88%b6%e7%bc%93%e5%ad%98%e6%97%b6%e9%97%b4%e4%bb%8d%e7%84%b6%e6%97%a0%e6%95%88" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>第二条路按文档来：自己写 Edge Rule，按响应 <code>content-type: text/html</code> + <code>status 200</code> 覆盖缓存时间 600 s。先后试了两种动作（OverrideCacheTime 与 OverrideCacheTimePublic），再不断收窄触发器（全站 → 单篇文章 → 去掉可疑触发器），结果一致：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">article try1   200 0.595s   cdn-cache: MISS    origin: EXPIRED
</span></span><span class="line"><span class="cl">article try2   200 0.076s   cdn-cache: MISS    origin: HIT
</span></span><span class="line"><span class="cl">article try3   200 0.078s   cdn-cache: MISS    origin: HIT</span></span></code></pre></div></div>
<p>边缘依旧 MISS，只是源站层自己命中（说明这时已有一层 nginx 代理缓存在工作——它属于第二层，与边缘无关）。排除触发器问题后结论收敛：<strong>卡点是源站显式发的 <code>Cache-Control: no-cache</code>——Bunny 拒绝为这类响应建缓存，什么 Edge Rule 都覆盖不了</strong>。官方两条路径在我们这里失效的原因由此明确：它们的前提都是源站先把头发对。</p>

<h3 class="relative group">弯路 3（侦查层面）：防火墙日志里的大量 BLOCK 不是攻击
    <div id="弯路-3侦查层面防火墙日志里的大量-block-不是攻击" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bc%af%e8%b7%af-3%e4%be%a6%e6%9f%a5%e5%b1%82%e9%9d%a2%e9%98%b2%e7%81%ab%e5%a2%99%e6%97%a5%e5%bf%97%e9%87%8c%e7%9a%84%e5%a4%a7%e9%87%8f-block-%e4%b8%8d%e6%98%af%e6%94%bb%e5%87%bb" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>排查早期（另一个掉线窗口）宿主防火墙日志持续刷着数千条 BLOCK 记录，第一反应很像被扫描或攻击。逐一核对策略后确认：那是防火墙默认拒绝策略对容器 DNS 查询的常规拦截——<strong>软件默认行为，不是攻击信号</strong>。它确实造成容器内 DNS 不可用等一系列副作用（另案修复），但与掉线窗口无关，窗口内源站全程 200。排障时先分清&quot;策略性拦截噪音&quot;和&quot;真正的 5xx/断流&quot;，否则会在默认行为上浪费整个调查阶段。</p>

<h3 class="relative group">最终方案：两层缓存 + 发布即清除
    <div id="最终方案两层缓存--发布即清除" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%80%e7%bb%88%e6%96%b9%e6%a1%88%e4%b8%a4%e5%b1%82%e7%bc%93%e5%ad%98--%e5%8f%91%e5%b8%83%e5%8d%b3%e6%b8%85%e9%99%a4" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>教训汇总成一句话：<strong>要让 CDN 缓存 HTML，得先让源站对&quot;值得缓存的请求&quot;发出可缓存的头</strong>。落地四步：</p>
<ol>
<li><strong>源站头正确化</strong>（WordPress mu-plugin，在 <code>send_headers</code> 钩子里按最终优先级覆盖）：
<ul>
<li>匿名 + 无查询串 + 非后台/API/Feed 的 HTML：<code>Cache-Control: public, max-age=0, s-maxage=600</code>（边缘缓存 10 分钟，浏览器每次验证）；</li>
<li>登录/评论者/密码页 cookie、wp-admin、AJAX、REST、查询串、预览、404：<code>Cache-Control: no-cache, no-store, must-revalidate, private</code>。</li>
</ul>
</li>
<li><strong>边缘层</strong>：关闭 Smart Cache（<code>EnableSmartCache=false</code>），让 Bunny 完全按源站头决定；只留一条 Edge Rule 做旁路——cookie 匹配 <code>wordpress_logged_in</code>/<code>wordpress_sec</code>/<code>wp-postpass</code>/<code>comment_author</code> 等，或路径匹配 <code>/wp-admin/*</code>、<code>/wp-login.php</code>、<code>/wp-json/*</code>、<code>/xmlrpc.php</code>、<code>/wp-cron.php</code>，一律缓存时间 0。</li>
<li><strong>源站层</strong>：控制面板的反向代理模板切成 caching（nginx <code>proxy_cache</code>：200 缓存 5 分钟，301/302 与 404 缓存 10 分钟）；cookie 与 POST 自动置 <code>$no_cache=1</code> 旁路；<code>X-Update</code> 请求头既绕过读取又写回新副本。</li>
<li><strong>发布即清除</strong>：<code>bunnycdn</code> 官方插件只有后台手动清除按钮、没有发布钩子，所以由同一个 mu-plugin 在发布/更新/评论事件里触发：CDN 全区 purge + 对受影响 URL 发带 <code>X-Update</code> 的回源请求刷新源站层。注意回源刷新<strong>必须请求干净 URL</strong>——nginx 缓存键包含查询串，带缓存破坏参数会写进另一个键，等于没刷新。</li>
</ol>
<p>另有一处与缓存无关的独立优化：给不存在的静态资源直接返 404。扫描器打的 <code>/wp-content/themes/xxx/style.css</code> 原本会被 <code>try_files → fallback</code> 交给 WordPress，渲染出 46.6 KB 的主题 404 页（又是一次完整 PHP 引导）。改为在 nginx 层对 <code>/wp-content/</code>、<code>/wp-includes/</code> 下带静态扩展名且文件不存在的请求直接返回 404（2.9 KB）。实现要点：<strong>必须用 <code>location ^~</code> 前缀写法</strong>——普通 <code>location ~</code> 会被控制面板 vhost 内嵌的正则位置抢先，写了不生效（同样是实测踩坑）。</p>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最终结构与实测数字（公网路径，全部经过 CDN）：</p>
<table>
	<thead>
			<tr>
					<th>请求类型</th>
					<th>走到哪</th>
					<th>耗时</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>匿名页面（已缓存）</td>
					<td>CDN 边缘命中</td>
					<td>约 55 ms，源站零请求</td>
			</tr>
			<tr>
					<td>匿名页面（新页 / 清除后首次）</td>
					<td>源站 nginx 缓存命中</td>
					<td>50–80 ms，仍不进 PHP</td>
			</tr>
			<tr>
					<td>登录 / 评论者、后台、API、POST、查询串</td>
					<td>直穿到 PHP</td>
					<td>正常渲染（设计如此）</td>
			</tr>
	</tbody>
</table>
<p>改造前同路径匿名页回源约 0.5 s；改造后匿名命中只要零头，而且<strong>源站请求量归零</strong>。</p>
<p><strong>洪峰复现（关键验收）</strong>：拿 09-17 的真实洪峰形态（60 个请求、并发 20、混打真实页面）重放：</p>
<ul>
<li>全部 200，平均 67 ms、最大 95 ms，没有一个超过 1 s；</li>
<li>源站 Apache 日志只多了 9 条（绝大多数请求没碰 PHP）；</li>
<li>源站负载从 0.13 升到 0.22，探针请求全程 50–70 ms 正常。</li>
</ul>
<p>对照改造前同类洪峰：504 + 30 秒级排队。那个&quot;1 分钟掉线&quot;的机制被从根上拿掉。</p>
<p><strong>发布即清除验证</strong>：手动执行一次 purge 路径后，边缘立即转 MISS，下一次请求自动回温 HIT；源站缓存条目由 <code>X-Update</code> 请求刷新而非等过期。</p>
<p><strong>怎么判断没解决（反向验收）</strong>：匿名页 <code>cdn-cache</code> 仍是 MISS，且源站访问日志与访客请求同步增长——两者出现任何一个，说明 HTML 又回到&quot;每次现拉&quot;状态（常见诱因：源站头又被某个插件改回去、Smart Cache 被重新打开、旁路规则误伤匿名流量）。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<ol>
<li><strong>先判缓存真伪，再谈缓存配置</strong>。三个响应头一次到位：
<ul>
<li><code>cdn-cache: MISS</code> 且 <code>cdn-cachedat</code> 等于请求当刻 → 每次都回源，没吃到边缘缓存；</li>
<li>同一批请求同步出现在源站日志 → 每次都跑了完整渲染；</li>
<li>对照静态资源是否 HIT → 区分&quot;CDN 坏了&quot;与&quot;HTML 没缓存&quot;。</li>
</ul>
</li>
<li><strong>查&quot;链路上有没有一层 HTML 缓存&quot;，三个看着有其实没有</strong>：<code>WP_CACHE=true</code> 但 <code>advanced-cache.php</code> 不存在（常量空转）；控制面板 microcache 需按域开启、vhost 没有对应指令等于没开；CDN 拉区若延续源站 <code>no-cache</code> 头，边缘也不会缓存 HTML。</li>
<li><strong>Bunny HTML 缓存配方（本文实测可跑通）</strong>：Smart Cache 关闭 → 源站 mu-plugin 只对&quot;匿名 + 无查询串&quot;的 HTML 发 <code>public, max-age=0, s-maxage=600</code>，其余一律 <code>no-cache, private</code> → 一条 Edge Rule 做 cookie/后台路径旁路 → 边缘与源站各配一层缓存 → 发布/评论时双层清除（全区 purge + <code>X-Update</code> 干净 URL 回源）。</li>
<li><strong>旁路清单别漏</strong>：登录/评论者/密码页 cookie、<code>wp-admin</code>、<code>wp-json</code>、<code>xmlrpc.php</code>、<code>wp-cron.php</code>、带查询串、POST、预览、404。漏掉任何一个，轻则缓存错页面，重则把登录后的页面发给别人。</li>
<li><strong>用 API 改边缘规则注意部分更新语义</strong>：未提交的字段会被重置为默认值，改完拉全量配置核对漂移。回滚路径各一步：删 mu-plugin 文件、模板切回 default、删旁路规则、Smart Cache 重新打开。</li>
</ol>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把这次调查串起来的其实是最后一条因果链：<strong>这套架构里，16 个 worker 的 php-fpm 动态处理池就是整站吞吐上限</strong>。CDN 不缓存 HTML，&ldquo;读一个页面&quot;和&quot;跑一次完整渲染&quot;就变成同一件事；池子被打满（抓取器、插件自动更新、扫描器 404），新请求全部排队直至超时——而监控探针探的就是贴着你首页的 PHP 渲染，和访客挤在同一池子里。池满那一刻，监控和访客看到完全相同的&quot;1 分钟黑洞&rdquo;。</p>
<p>之前把池子从 8 扩到 16、把图片交给 CDN 优化，都只是把黑洞往后推了推。真正让它消失的，是承认 CDN 的 HTML 缓存坑、把源站头发对、再把&quot;页面浏览&quot;从 PHP 渲染里剥离出来。给博客做缓存的价值也在这里：它不只是面板上的命中率数字，更是让&quot;监控探针&quot;和&quot;真实访客&quot;两种流量不再挤同一个池子。以后若再见同型症状，先看两个响应头，再决定要不要碰防火墙。</p>
]]></content:encoded>
    </item>
    <item>
      <title>把自建博客从 Ghost 6 换成 Hugo &#43; Blowfish 静态站：实测数据与发布管线实录</title>
      <link>https://slop.lun.sh/ghost-to-hugo-static-site/</link>
      <pubDate>Fri, 18 Sep 2026 20:00:00 +0800</pubDate>
      <guid isPermaLink="false">https://slop.lun.sh/ghost-to-hugo-static-site/</guid>
      <category>Hugo</category>
      <category>静态站</category>
      <category>CDN</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>本站原先跑在 Ghost 6 上（Node/Express + SQLite，单容器）。这篇记录把它整体换成 Hugo + Blowfish 静态站的全过程：迁移前实测的对照数据、内容与 URL 的搬家方式、版本化部署与回滚，以及过程中踩到的十个坑。适用环境：Ghost 6.64 / Hugo 0.166.0 extended + Blowfish 主题 / nginx:alpine 容器 + Nginx Proxy Manager + systemd / 前置 Bunny CDN，2026-09-18 实测。结论先说：<strong>值得迁，但&quot;省内存、省带宽&quot;不是理由</strong>——客户端那一千 KB 里最肥的一块与换不换平台无关；真正的理由是内容变成 git 里的 Markdown、发布链路可验证、回滚变成一条命令。</p>
</blockquote>
<h2 class="relative group">一、迁移前先把&quot;更轻便&quot;量出来
    <div id="一迁移前先把更轻便量出来" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e8%bf%81%e7%a7%bb%e5%89%8d%e5%85%88%e6%8a%8a%e6%9b%b4%e8%bd%bb%e4%be%bf%e9%87%8f%e5%87%ba%e6%9d%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>迁移前本站的规模：4 篇文章、5 个标签、3 个附件脚本、一共 12 条 URL。全部实测数据如下（不是文档推断，是在真实环境里量出来的）。</p>
<p>服务端：</p>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>迁移前（Ghost 6.64）</th>
					<th>迁移后（Hugo + Blowfish）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>运行形态</td>
					<td>Node/Express 服务端渲染 + SQLite</td>
					<td>纯静态文件，nginx:alpine</td>
			</tr>
			<tr>
					<td>镜像体积</td>
					<td>639 MB</td>
					<td>62.9 MB</td>
			</tr>
			<tr>
					<td>常驻内存</td>
					<td>117.7 MiB</td>
					<td>4.9 MiB</td>
			</tr>
			<tr>
					<td>数据库 / 后台</td>
					<td>SQLite + Admin API + 登录 + 两步验证</td>
					<td>无</td>
			</tr>
			<tr>
					<td>发布通路</td>
					<td>Admin API（签名 JWT + 一堆必填参数）</td>
					<td>写 <code>.md</code> → 构建 → 同步</td>
			</tr>
			<tr>
					<td>渲染开销</td>
					<td>每个请求现渲染</td>
					<td>构建一次 148–190 ms（30 页）</td>
			</tr>
			<tr>
					<td>内容体积</td>
					<td>数据目录 21 MB（其中主题 15 MB、日志 2.7 MB、数据库 2.1 MB）</td>
					<td>仓库 + 构建产物 861 KB</td>
			</tr>
	</tbody>
</table>
<p>客户端（页面全资源，含 CSS/JS/字体/图标）：</p>
<table>
	<thead>
			<tr>
					<th>页面</th>
					<th>迁移前</th>
					<th>迁移后</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>首页</td>
					<td>1008 KB（9 个子资源）</td>
					<td>218 KB 原始 / 61 KB gzip</td>
			</tr>
			<tr>
					<td>文章页</td>
					<td>1030 KB</td>
					<td>232 KB 原始 / 68 KB gzip</td>
			</tr>
			<tr>
					<td>体积大头</td>
					<td><code>mermaid.min.js</code> 764.8 KB <strong>每页无条件加载</strong>、搜索 82.5 KB、KaTeX 74.1 KB、代码高亮 59.5 KB</td>
					<td>主样式 128.7 KB（gzip 21 KB）、主脚本 33.2 KB（gzip 11.4 KB）、缩放 8.6 KB</td>
			</tr>
			<tr>
					<td>mermaid</td>
					<td>每页强载</td>
					<td>构建产物里 0 字节（没有页面用就不发）</td>
			</tr>
	</tbody>
</table>
<p>上线后实测：首页联网传输 <strong>63.7 KB</strong>（迁移前 1008 KB，大约 1/16）。</p>
<p><strong>但客户端这份收益不该记在迁移头上。</strong> 那 764.8 KB 的 mermaid 是旧主题里一行无条件加载造成的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">script</span> <span class="na">defer</span> <span class="na">src</span><span class="o">=</span><span class="s">&#34;{{asset &#39;js/mermaid.min.js&#39;}}&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">script</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>全站没有任何一张 mermaid 图，把这一行删掉或改成按需判断，留在原平台也能拿到同样的页面瘦身。同理静态资源缓存、图片优化这些，都是 CDN 与主题层面的事，与换不换引擎无关。</p>

<h2 class="relative group">二、为什么迁：维护面，不是内存
    <div id="二为什么迁维护面不是内存" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e4%b8%ba%e4%bb%80%e4%b9%88%e8%bf%81%e7%bb%b4%e6%8a%a4%e9%9d%a2%e4%b8%8d%e6%98%af%e5%86%85%e5%ad%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把迁移前的账摊开看，真正的过度工程在 CMS 这一侧：4 篇纯文本内容，养着一套完整的 Node 运行时 + SQLite + Admin API + 登录 + 两步验证 + 主题模板手术，外加一条相当脆弱的发布路径——签名用的是十六进制密钥（必须先解码再作 HMAC key，用错就 401）、更新内容必须带特定参数否则静默不生效、必须有乐观锁字段、某些接口直接抛 501 只能绕过 API 改数据库、换新设备登录会被强制邮箱验证码拦下。这些都不是能力，是维护面。</p>
<p>换成静态站之后，值得写在纸上的收益只有三条：</p>
<ol>
<li><strong>内容主权</strong>。内容从数据库行变成 git 仓库里的 Markdown 文件，版本、差异、审阅、回滚、增量全是现成的。</li>
<li><strong>发布链路可验证</strong>。写 <code>.md</code> → 构建 → 同步，构建可以在本地跑通再上线；运行态的故障面（Node 运行时、数据库、后台登录）收缩成一个&quot;构建管线&quot;，而它失败时线上可以不受影响。</li>
<li><strong>现在迁最便宜</strong>。4 篇内容、无图片、无会员、无评论、无注册，URL 一共 12 条。内容越多、外链越多，迁移只会更贵。</li>
</ol>
<p>反过来说，<strong>不建议为了省内存而迁</strong>：宿主机的内存本来就不缺这一百多 MiB；而迁移会把&quot;跟着 CMS 镜像升级&quot;的维护精力，换成&quot;Hugo 版本窗口 + 主题兼容&quot;的维护精力——故障面是换了种类，不是消失了。</p>
<p>唯一不可逆的东西是 <strong>URL 与订阅（RSS 的 <code>&lt;guid&gt;</code>）</strong>，所以整套方案按&quot;先保 URL 与订阅、再谈别的&quot;的顺序设计。</p>

<h2 class="relative group">三、处置过程
    <div id="三处置过程" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%a4%84%e7%bd%ae%e8%bf%87%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">1. 内容搬家：从数据库的 HTML 列，而不是导出 JSON
    <div id="1-内容搬家从数据库的-html-列而不是导出-json" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#1-%e5%86%85%e5%ae%b9%e6%90%ac%e5%ae%b6%e4%bb%8e%e6%95%b0%e6%8d%ae%e5%ba%93%e7%9a%84-html-%e5%88%97%e8%80%8c%e4%b8%8d%e6%98%af%e5%af%bc%e5%87%ba-json" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>内容源直接取 CMS 数据库的 <code>html</code> 列（避免二手导出数据），用一次性脚本转成 Markdown。保真度实测：表格（6 张）、围栏代码块（含语言标注）、引用块全部保留；把两边归一化后做 token 级比对，差异只剩 Markdown 记号本身（转义下划线、分隔线、列表序号）。</p>
<p>过程中有一个必须知道的坑：<strong>CMS 在数据库里把站点地址存成了占位符</strong>（形如 <code>__GHOST_URL__</code>），渲染时才替换成真实地址。直接迁移会得到一堆 <code>__GHOST_URL__/content/files/...</code> 的死链，脚本里必须把它替换成站内相对路径。附件（3 个 PowerShell 脚本）落到 <code>static/content/files/&lt;年&gt;/&lt;月&gt;/</code>，URL 与迁移前逐字节一致，构建即发布。</p>
<p>清洗分两步：先用扫描脚本列出所有反斜杠转义（这个平台的转换器会给词内下划线加转义），确认哪些是 Markdown 记号、哪些是 Windows 路径里的真反斜杠，<strong>围栏代码块一律不动</strong>，再动手还原。</p>

<h3 class="relative group">2. URL 保平：12 条旧地址一条不漏
    <div id="2-url-保平12-条旧地址一条不漏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#2-url-%e4%bf%9d%e5%b9%b312-%e6%9d%a1%e6%97%a7%e5%9c%b0%e5%9d%80%e4%b8%80%e6%9d%a1%e4%b8%8d%e6%bc%8f" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>文章 URL 形态用配置项固定住（<code>[permalinks] posts = &quot;/:slug/&quot;</code>），所以三篇文章的地址原样不变。需要额外处理的是其余地址：</p>
<table>
	<thead>
			<tr>
					<th>旧地址（CMS）</th>
					<th>新地址</th>
					<th>处理方式</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>3 篇文章 + 首页 + 关于页</td>
					<td>同名</td>
					<td>配置对齐，无需处理</td>
			</tr>
			<tr>
					<td><code>/tag/&lt;slug&gt;/</code>（5 个标签）</td>
					<td><code>/tags/&lt;slug&gt;/</code></td>
					<td>一条 nginx 正则 301 全量转过去</td>
			</tr>
			<tr>
					<td><code>/author/&lt;名字&gt;/</code></td>
					<td><code>/about/</code></td>
					<td>nginx 301</td>
			</tr>
			<tr>
					<td><code>/rss/</code>、<code>/rss</code></td>
					<td><code>/index.xml</code></td>
					<td>nginx 301（两个都实测跳转成功）</td>
			</tr>
			<tr>
					<td>占位帖 <code>/coming-soon/</code></td>
					<td><code>/</code></td>
					<td>nginx 301（内容本身不迁移：它是订阅引导页，本站没有会员，迁过去只会带出死链）</td>
			</tr>
			<tr>
					<td>一条历史改名的旧 slug</td>
					<td>现文章地址</td>
					<td>nginx 301</td>
			</tr>
	</tbody>
</table>
<p>标签页有个静态站特有的坑：<strong>新引擎里给标签写 <code>slug:</code> 不生效（实测 0.166 版本），必须写 <code>url:</code></strong>，否则标签页地址会变成中文名的拼音或原文，与旧地址对不上。写法是给每个标签建一个索引页，front matter 里写 <code>url: &quot;/tags/&lt;旧 slug&gt;/&quot;</code>。</p>
<p>还有一个只有反代环境才会遇到的坑：<strong>301 的目标必须写死 <code>https://</code></strong>。源站在反向代理后面，看到的请求协议是 <code>http</code>，如果 301 用相对路径或 <code>$scheme</code>，访问者会被降级跳回 <code>http</code> 再被跳一次。正确写法是显式 <code>return 301 https://$host/&lt;新路径&gt;;</code>。</p>

<h3 class="relative group">3. RSS 订阅连续性：<code>&lt;guid&gt;</code> 必须是旧的那一串
    <div id="3-rss-订阅连续性guid-必须是旧的那一串" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#3-rss-%e8%ae%a2%e9%98%85%e8%bf%9e%e7%bb%ad%e6%80%a7guid-%e5%bf%85%e9%a1%bb%e6%98%af%e6%97%a7%e7%9a%84%e9%82%a3%e4%b8%80%e4%b8%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这是整个迁移里最容易翻车、又最容易被忽略的一处。RSS 条目的 <code>&lt;guid&gt;</code> 是不透明字符串，迁移前的引擎用的是数据库内部的文章 id；而 Hugo 默认拿永久链接当 <code>&lt;guid&gt;</code>。两者一换，<strong>所有老订阅者会在切换当天把已有的旧文章当成新文章重收一遍</strong>。</p>
<p>处理方式：从旧站的 <code>/rss/</code> 里把每篇文章的 <code>&lt;guid&gt;</code> 取回来写进 front matter，再用一个自定义的 <code>layouts/rss.xml</code> 覆盖默认模板——front matter 里有 <code>guid</code> 就用它，没有才退回永久链接；同时用 <code>mainSections = [&quot;posts&quot;]</code> 把独立页面（关于页等）踢出 feed。</p>
<p>模板里另有一个语法坑：XML 声明必须走模板函数输出，字面写会被转义成 <code>&amp;lt;?xml</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go-html-template" data-lang="go-html-template"><span class="line"><span class="cl"><span class="cp">{{</span><span class="w"> </span><span class="k">printf</span><span class="w"> </span><span class="s">&#34;&lt;?xml version=\&#34;1.0\&#34; encoding=\&#34;utf-8\&#34; standalone=\&#34;yes\&#34;?&gt;&#34;</span><span class="w"> </span><span class="o">|</span><span class="w"> </span><span class="nx">safeHTML</span><span class="w"> </span><span class="cp">}}</span></span></span></code></pre></div></div>
<p>验证方式是逐条比对：feed 条数、每条 <code>&lt;guid&gt;</code> 与线上旧 feed 逐个一致、XML 可解析。</p>

<h3 class="relative group">4. 部署：版本化目录 + 符号链接原子切换
    <div id="4-部署版本化目录--符号链接原子切换" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#4-%e9%83%a8%e7%bd%b2%e7%89%88%e6%9c%ac%e5%8c%96%e7%9b%ae%e5%bd%95--%e7%ac%a6%e5%8f%b7%e9%93%be%e6%8e%a5%e5%8e%9f%e5%ad%90%e5%88%87%e6%8d%a2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>落地结构：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">/srv/site/
</span></span><span class="line"><span class="cl">├── src/        内容仓库的工作副本（构建前 fetch + reset）
</span></span><span class="line"><span class="cl">├── releases/   每次构建一个带时间戳的目录
</span></span><span class="line"><span class="cl">├── bin/        hugo 二进制（锁定版本，不跟随包管理器）
</span></span><span class="line"><span class="cl">├── scripts/    构建与部署脚本
</span></span><span class="line"><span class="cl">├── deploy/     systemd 单元等部署配置
</span></span><span class="line"><span class="cl">├── logs/       部署与自动部署日志
</span></span><span class="line"><span class="cl">└── current -&gt; releases/&lt;时间戳&gt;   符号链接，网站根目录指向它</span></span></code></pre></div></div>
<p>部署脚本的语义固定为六步：拉取内容 → 构建到新的 <code>releases/&lt;时间戳&gt;</code> → 校验关键产物非空 → <strong>原子替换 <code>current</code> 符号链接</strong> → 配置有变化则同步并让容器 reload → 只保留最近 5 份。</p>
<p>这样做的意义是&quot;发不出去&quot;不等于&quot;站挂了&quot;：构建失败或校验不通过时 <code>current</code> 不动，线上继续跑上一版。</p>
<p>服务容器是 <code>nginx:alpine</code>，只接内网（不映射公网端口），由反向代理转发，前面挂 CDN。回滚有两档：</p>
<ul>
<li>回上一版产物：把 <code>current</code> 指回上一个 release 目录（nginx 每个请求都会重新解析符号链接，不需要重启）。</li>
<li>回旧平台：把反向代理的转发目标改回 CMS 容器，再把它启动起来（容器与数据一直保留，切换前的数据库行与生成的配置文件都有备份）。</li>
</ul>
<p><strong>Hugo 的版本是硬约束</strong>：主题声明了兼容窗口（<code>extended 0.163–0.166</code>），所以本地二进制、服务器上的二进制、主题三者必须一起动，不追新。</p>
<p>自动部署用 systemd timer 每 5 分钟检查一次远端仓库：有新提交就跑部署脚本，没有就静默退出（不写日志，避免日志噪音），失败时发一条即时通知。两个实测细节：<strong>timer 的多时刻要拆成多行 <code>OnCalendar=</code> 写</strong>（单行逗号串或 <code>*:0/5</code> 这类重复语法会解析失败或算不出下一次排期）；验收要看服务单元的启动时间戳是否往前推进，只看 <code>list-timers</code> 的&quot;下次触发时间&quot;不算数。</p>
<p>整套东西就这三份文件（部署脚本 + 自动部署脚本 + systemd 单元），已经按本站的编写原则原样附在下面——站点路径与容器名做了替换，其余逻辑一字未改，直接放到自己的服务器上即可用：</p>





<div
  id="accordion-1162bc4a520709cde3ba9a1fb46a563c"
  class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden"
  data-accordion="collapse"
  data-accordion-separated="false"
>



  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>deploy/deploy.sh —— 构建、校验、原子切换（80 行）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># 站点部署脚本（在源站上跑）：</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   git pull → hugo 构建到新 release → 校验关键产物 → 原子切换 current 符号链接</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   → 站点配置有变化则同步给容器并 reload → 清理旧 release</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 设计要点：</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * 构建与部署解耦：构建失败/校验不通过时不动 current，线上仍是上一版</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * 版本锁定：hugo 二进制固定在 $BASE/bin/hugo，不跟随系统包管理器</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * 可回滚：releases/ 下保留最近 $KEEP 份，回滚 = 重指 current 符号链接</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * current 是符号链接，容器挂的是 $BASE 而不是 current</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     —— 挂符号链接会在容器创建时被解析死，永远停在旧版本</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">BASE</span><span class="o">=</span>/srv/site              <span class="c1"># 改成你的部署根目录（不要用 /root 下的路径）</span>
</span></span><span class="line"><span class="cl"><span class="nv">SRC</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/src&#34;</span>             <span class="c1"># 内容仓库工作副本</span>
</span></span><span class="line"><span class="cl"><span class="nv">REL</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/releases&#34;</span>        <span class="c1"># 每次构建一个带时间戳的目录</span>
</span></span><span class="line"><span class="cl"><span class="nv">HUGO</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/bin/hugo&#34;</span>       <span class="c1"># 固定版本的 hugo 二进制</span>
</span></span><span class="line"><span class="cl"><span class="nv">LOG</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/logs/deploy.log&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">CONF</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/deploy/nginx-site.conf&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">CTR</span><span class="o">=</span>site                    <span class="c1"># 静态站容器名</span>
</span></span><span class="line"><span class="cl"><span class="nv">KEEP</span><span class="o">=</span><span class="m">5</span>                      <span class="c1"># 保留最近几份 release</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 必须存在且非空的产物：换成你自己的页面路径，附件也可以列进来</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROBE_FILES</span><span class="o">=</span><span class="s2">&#34;index.html about/index.html index.xml&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">SITE_MARK</span><span class="o">=</span><span class="s2">&#34;站点标记&#34;</span>        <span class="c1"># 首页必然出现的字符串，用于容器自检</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/logs&#34;</span> <span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/deploy&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span> 2&gt;<span class="p">&amp;</span><span class="m">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> deploy 开始 ===&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">git fetch --quiet origin main
</span></span><span class="line"><span class="cl">git reset --hard --quiet origin/main
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;commit: </span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2"> </span><span class="k">$(</span>git log -1 --pretty<span class="o">=</span>%s<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>date +%Y%m%d-%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">OUT</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$REL</span><span class="s2">/</span><span class="nv">$STAMP</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">mkdir -p <span class="s2">&#34;</span><span class="nv">$OUT</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! <span class="s2">&#34;</span><span class="nv">$HUGO</span><span class="s2">&#34;</span> --source <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">&#34;</span> --destination <span class="s2">&#34;</span><span class="nv">$OUT</span><span class="s2">&#34;</span> --environment production --cleanDestinationDir<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;FAIL: hugo 构建失败，current 未改动&#34;</span>
</span></span><span class="line"><span class="cl">  rm -rf <span class="s2">&#34;</span><span class="nv">$OUT</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 回读断言：关键产物必须存在且非空，否则不切换（宁可发不出去，也不能上线半成品）</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> f in <span class="nv">$PROBE_FILES</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> ! -s <span class="s2">&#34;</span><span class="nv">$OUT</span><span class="s2">/</span><span class="nv">$f</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;FAIL: 产物缺少 </span><span class="nv">$f</span><span class="s2">，current 未改动&#34;</span>
</span></span><span class="line"><span class="cl">    rm -rf <span class="s2">&#34;</span><span class="nv">$OUT</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 原子切换</span>
</span></span><span class="line"><span class="cl">ln -sfn <span class="s2">&#34;</span><span class="nv">$OUT</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/.current.new&#34;</span>
</span></span><span class="line"><span class="cl">mv -Tf <span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/.current.new&#34;</span> <span class="s2">&#34;</span><span class="nv">$BASE</span><span class="s2">/current&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 站点配置随仓库版本化：有变化就同步到稳定路径并 reload</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> -f <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">/deploy/nginx-site.conf&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> ! cmp -s <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">/deploy/nginx-site.conf&#34;</span> <span class="s2">&#34;</span><span class="nv">$CONF</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  cp <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">/deploy/nginx-site.conf&#34;</span> <span class="s2">&#34;</span><span class="nv">$CONF</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> docker ps --format <span class="s1">&#39;{{.Names}}&#39;</span> <span class="p">|</span> grep -qx <span class="s2">&#34;</span><span class="nv">$CTR</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    docker <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$CTR</span><span class="s2">&#34;</span> nginx -s reload <span class="o">&amp;&amp;</span> <span class="nb">echo</span> <span class="s2">&#34;站点配置已更新并 reload&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 容器自检：确认它读到的就是刚切换的版本</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> docker ps --format <span class="s1">&#39;{{.Names}}&#39;</span> <span class="p">|</span> grep -qx <span class="s2">&#34;</span><span class="nv">$CTR</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> docker <span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$CTR</span><span class="s2">&#34;</span> wget -qO- http://127.0.0.1/index.html <span class="p">|</span> grep -q <span class="s2">&#34;</span><span class="nv">$SITE_MARK</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;容器自检: OK&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;WARN: 容器自检未通过（首页未出现站点标记）&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 只保留最近 $KEEP 份 release</span>
</span></span><span class="line"><span class="cl">ls -1dt <span class="s2">&#34;</span><span class="nv">$REL</span><span class="s2">&#34;</span>/*/ 2&gt;/dev/null <span class="p">|</span> tail -n +<span class="k">$((</span>KEEP <span class="o">+</span> <span class="m">1</span><span class="k">))</span> <span class="p">|</span> xargs -r rm -rf
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> deploy 成功 -&gt; </span><span class="nv">$OUT</span><span class="s2"> ===&#34;</span></span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>deploy/autodeploy.sh —— 定时检查远端提交（失败才通知，40 行）</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># 自动部署检查（systemd timer 每 5 分钟跑一次）</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * 只有远程有新提交才触发部署（无变化时静默退出，不写日志、不打扰）</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * 部署失败 → 发一条通知（凭据只在服务器端读取，绝不回显）</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   * 部署本身的安全网在 deploy.sh：构建失败/产物缺失不切换 current，线上保持上一版</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -uo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">SRC</span><span class="o">=</span>/srv/site/src
</span></span><span class="line"><span class="cl"><span class="nv">LOG</span><span class="o">=</span>/srv/site/logs/autodeploy.log
</span></span><span class="line"><span class="cl"><span class="nv">DEPLOY</span><span class="o">=</span>/srv/site/scripts/deploy.sh
</span></span><span class="line"><span class="cl"><span class="nv">ENV_FILE</span><span class="o">=</span>/path/to/.env      <span class="c1"># 内含通知凭据（下面示例用 Telegram）</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">notify<span class="o">()</span> <span class="o">{</span>   <span class="c1"># $1 = 文本；没有配置凭据就安静跳过</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -r <span class="s2">&#34;</span><span class="nv">$ENV_FILE</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="k">return</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">  <span class="nb">local</span> tok chat
</span></span><span class="line"><span class="cl">  <span class="nv">tok</span><span class="o">=</span><span class="k">$(</span>grep -m1 <span class="s1">&#39;^TELEGRAM_BOT_TOKEN=&#39;</span> <span class="s2">&#34;</span><span class="nv">$ENV_FILE</span><span class="s2">&#34;</span> <span class="p">|</span> cut -d<span class="o">=</span> -f2-<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">chat</span><span class="o">=</span><span class="k">$(</span>grep -m1 <span class="s1">&#39;^TELEGRAM_HOME_CHANNEL=&#39;</span> <span class="s2">&#34;</span><span class="nv">$ENV_FILE</span><span class="s2">&#34;</span> <span class="p">|</span> cut -d<span class="o">=</span> -f2-<span class="k">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$tok</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$chat</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">||</span> <span class="k">return</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">  curl -s -o /dev/null -X POST <span class="s2">&#34;https://api.telegram.org/bot</span><span class="si">${</span><span class="nv">tok</span><span class="si">}</span><span class="s2">/sendMessage&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --data-urlencode <span class="s2">&#34;chat_id=</span><span class="si">${</span><span class="nv">chat</span><span class="si">}</span><span class="s2">&#34;</span> --data-urlencode <span class="s2">&#34;text=</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$SRC</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">if</span> ! git fetch --quiet origin main<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> fetch 失败（网络/自建 Git 不可达），跳过本轮&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">local_head</span><span class="o">=</span><span class="k">$(</span>git rev-parse HEAD<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">remote_head</span><span class="o">=</span><span class="k">$(</span>git rev-parse origin/main<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$local_head</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="s2">&#34;</span><span class="nv">$remote_head</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>     <span class="c1"># 无变化：静默</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> 检测到新提交 </span><span class="nv">$local_head</span><span class="s2"> -&gt; </span><span class="nv">$remote_head</span><span class="s2">，开始部署&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> bash <span class="s2">&#34;</span><span class="nv">$DEPLOY</span><span class="s2">&#34;</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> 自动部署完成&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;</span><span class="k">$(</span>date -Is<span class="k">)</span><span class="s2"> 自动部署失败（详见 deploy.log），current 未改动&#34;</span> &gt;&gt;<span class="s2">&#34;</span><span class="nv">$LOG</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  notify <span class="s2">&#34;站点自动部署失败：新提交 </span><span class="si">${</span><span class="nv">remote_head</span><span class="p">:</span><span class="nv">0</span><span class="p">:</span><span class="nv">8</span><span class="si">}</span><span class="s2"> 构建或校验未通过，线上仍是上一版。&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span></span></span></code></pre></div></div>

  </div>
</details>




  










<details
  class="group border-none"
  data-accordion-item
  
>
  <summary class="flex w-full cursor-pointer items-center justify-between gap-4 px-4 py-3 text-left text-lg font-semibold text-neutral-900 dark:text-neutral-100">
    <span class="flex items-center gap-2">
      
      <span>deploy/systemd/site-autodeploy.{service,timer} —— 两个单元文件</span>
    </span>
    <span class="accordion-chevron ms-auto flex h-5 w-5 items-center justify-center print:hidden">
      <span class="relative block icon"><svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 20 20"
  fill="currentColor"
  aria-hidden="true"
>
  <path
    fill-rule="evenodd"
    d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
    clip-rule="evenodd"
  />
</svg>
</span>
    </span>
  </summary>
<div class="px-4 pb-4 text-neutral-700 dark:text-neutral-300">
    <div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/site-autodeploy.service</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">检查自建 Git 仓库是否有新提交并按需部署（oneshot）</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network-online.target docker.service</span>
</span></span><span class="line"><span class="cl"><span class="na">Wants</span><span class="o">=</span><span class="s">network-online.target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 不要 RemainAfterExit=yes（会让 unit 永远 active (exited)，timer 的“启动”变空操作）</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/bin/bash /srv/site/scripts/autodeploy.sh</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># /etc/systemd/system/site-autodeploy.timer</span>
</span></span><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">每 5 分钟检查站点仓库是否有新提交</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Timer]</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 多时刻必须拆成多行 OnCalendar——单行逗号串 / *:0/5 这类重复语法会解析失败或算不出下一次排期</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:00:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:05:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:10:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:15:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:20:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:25:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:30:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:35:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:40:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:45:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:50:00</span>
</span></span><span class="line"><span class="cl"><span class="na">OnCalendar</span><span class="o">=</span><span class="s">*-*-* *:55:00</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 不要 Persistent=true、不要 RandomizedDelaySec</span>
</span></span><span class="line"><span class="cl"><span class="na">Unit</span><span class="o">=</span><span class="s">site-autodeploy.service</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">timers.target</span></span></span></code></pre></div></div>
<p>装好之后：<code>systemctl daemon-reload &amp;&amp; systemctl enable --now site-autodeploy.timer</code>；想立刻部署一次就直接 <code>bash /srv/site/scripts/deploy.sh</code>。</p>
  </div>
</details>

</div>

<style>
  #accordion-1162bc4a520709cde3ba9a1fb46a563c > details + details {
    border-top: 1px solid rgb(var(--color-neutral-200));
  }
  .dark #accordion-1162bc4a520709cde3ba9a1fb46a563c > details + details {
    border-top-color: rgb(var(--color-neutral-700));
  }
</style>

<style>
  #accordion-1162bc4a520709cde3ba9a1fb46a563c details[data-accordion-item] > summary .accordion-chevron {
    transform: rotate(-90deg);
    transition: transform 200ms ease-in-out;
  }
  #accordion-1162bc4a520709cde3ba9a1fb46a563c details[data-accordion-item][open] > summary .accordion-chevron {
    transform: rotate(0deg);
  }
</style>

<script>
  (() => {
    const root = document.getElementById("accordion-1162bc4a520709cde3ba9a1fb46a563c");
    if (!root) return;
    const items = root.querySelectorAll("details[data-accordion-item]");
    items.forEach((item) => {
      item.addEventListener("toggle", () => {
        if (!item.open) return;
        items.forEach((other) => {
          if (other !== item) other.removeAttribute("open");
        });
      });
    });
  })();
</script>



<h3 class="relative group">5. 十个坑（全部实测）
    <div id="5-十个坑全部实测" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#5-%e5%8d%81%e4%b8%aa%e5%9d%91%e5%85%a8%e9%83%a8%e5%ae%9e%e6%b5%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<table>
	<thead>
			<tr>
					<th>#</th>
					<th>现象</th>
					<th>机制</th>
					<th>处理</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>容器里所有页面 403，服务器上直接 curl 却正常</td>
					<td>站点目录放在 <code>/root</code> 下，而 <code>/root</code> 是 0700，容器内的 nginx worker（非 root）穿不进去；301 之所以正常，是因为它在 <code>return</code> 阶段就返回了，根本不读文件</td>
					<td>站点目录移到 <code>/srv</code> 下</td>
			</tr>
			<tr>
					<td>2</td>
					<td>切换版本后容器还在发旧内容</td>
					<td>bind mount 在容器创建时解析一次符号链接，之后换链接容器仍指旧目录</td>
					<td>容器挂载<strong>父目录</strong>，nginx 里 <code>root</code> 指向 <code>.../current</code></td>
			</tr>
			<tr>
					<td>3</td>
					<td>服务器上 <code>Permission denied</code>，脚本跑不起来</td>
					<td>Windows 提交时丢了可执行位（100755 → 100644）</td>
					<td><code>git update-index --chmod=+x &lt;脚本&gt;</code> 后重新提交</td>
			</tr>
			<tr>
					<td>4</td>
					<td>从本机 ssh 推私有仓超时</td>
					<td>自建 Git 服务的 SSH 端口从公网不可达（CDN 与反代只放 443）；本机代理的&quot;端口探测&quot;会假报开放</td>
					<td>推送走 HTTPS + 访问令牌（令牌存系统凭据管理器，不进日志）</td>
			</tr>
			<tr>
					<td>5</td>
					<td>改了反向代理的转发目标，流量却没切过去</td>
					<td>反向代理的转发目标同时存在于数据库和生成的 vhost 文件里，改数据库不会重写文件</td>
					<td>两处一起改，<code>nginx -t</code> 通过后 reload；改前先备份</td>
			</tr>
			<tr>
					<td>6</td>
					<td>CDN 的缓存清除 API 报 401</td>
					<td>清除用的密钥不在证书配置的 JSON 字段里，而是嵌在一段说明文本中</td>
					<td>从文本里取出后单独保存；清缓存是<strong>逐 URL</strong> 调用，主机名级别不可用（返回 500）</td>
			</tr>
			<tr>
					<td>7</td>
					<td>改了主题/升了引擎版本的某天构建突然崩</td>
					<td>主题声明的 Hugo 兼容窗口是滚动的，越界即失败</td>
					<td>三处（本地、服务器、主题）一起动，并锁定具体版本</td>
			</tr>
			<tr>
					<td>8</td>
					<td>一篇 2300 汉字的文章显示&quot;302 字 · 2 分钟&quot;</td>
					<td>引擎默认按空格切词统计字数与阅读时间，中文没有空格</td>
					<td>打开 CJK 语言开关后实测变为&quot;3817 字 · 8 分钟&quot;（含代码与表格字符）</td>
			</tr>
			<tr>
					<td>9</td>
					<td>站点模板里写的新样式不生效</td>
					<td>主题的样式表是作者预编译后提交的，只包含主题自己用过的类，新类名根本不存在于产物里</td>
					<td>新样式写进站点自己的 CSS 文件（主题会自动加载），用自定义类名</td>
			</tr>
			<tr>
					<td>10</td>
					<td>301 跳转把访客降级回 http</td>
					<td>源站在反向代理后面，看到的协议是 http</td>
					<td>301 目标显式写 <code>https://$host/...</code></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">四、验证
    <div id="四验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上线后的核对清单（全部实测）：</p>
<table>
	<thead>
			<tr>
					<th>检查项</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>三篇文章 + 首页 + 关于页</td>
					<td>全 200</td>
			</tr>
			<tr>
					<td>附件直链</td>
					<td>200，路径与迁移前逐字节一致</td>
			</tr>
			<tr>
					<td>旧地址（<code>/rss/</code>、<code>/tag/*</code>、<code>/author/*</code>、占位帖、历史 slug）</td>
					<td>全部 301 且指向 https</td>
			</tr>
			<tr>
					<td>线上 RSS 的 <code>&lt;guid&gt;</code></td>
					<td>与迁移前逐条一致</td>
			</tr>
			<tr>
					<td>首页是否残留旧引擎痕迹（关键字扫描）</td>
					<td>0 处</td>
			</tr>
			<tr>
					<td>容器状态</td>
					<td>静态站容器运行中，4.9 MiB；旧容器已停但保留（回滚用）</td>
			</tr>
			<tr>
					<td>首页联网传输</td>
					<td>63.7 KB（迁移前 1008 KB）</td>
			</tr>
	</tbody>
</table>
<p>两件必须做的事：</p>
<p><strong>回读断言。</strong> &ldquo;推送成功&quot;不等于&quot;已上线&rdquo;。构建静默崩溃、缓存没清、权限位丢了，都能让推送成功而页面不变；部署脚本的退出码看不出这些。所以每次发布后都要回读一次目标 URL，确认新内容真的可见——这是静态站最典型的失败模式。</p>
<p><strong>CDN 缓存清除。</strong> 前置 CDN 会缓存 HTML，改版后不清缓存读者还会看到旧页。清除接口按 URL 逐个调用，切换当天把那十几条地址（含已废弃的旧地址）全部清了一遍。</p>

<h2 class="relative group">五、留给后来者的清单
    <div id="五留给后来者的清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e7%95%99%e7%bb%99%e5%90%8e%e6%9d%a5%e8%80%85%e7%9a%84%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>迁移前先量数据：镜像、内存、页面全资源体积都实测再拍板；动机锚定&quot;内容主权 + 发布链路&quot;，别为省内存带宽而迁。</li>
<li>内容源取数据库的 HTML 列，别用导出 JSON；先查有没有站点地址占位符。</li>
<li>URL 保平做成清单：文章用永久链接配置对齐、标签页写 <code>url:</code> 而不是 <code>slug:</code>、其余用 nginx 301 收口，目标写死 https。</li>
<li>RSS 的 <code>&lt;guid&gt;</code> 沿用旧值，否则老订阅者重收旧文；XML 声明用模板函数输出。</li>
<li>部署用&quot;版本化目录 + 符号链接原子切换 + 产物校验&quot;，构建失败不动线上；只留最近几份。</li>
<li>站点目录别放 <code>/root</code>（0700 会让容器里的 worker 全站 403）；容器别直接挂符号链接，挂父目录。</li>
<li>附件进版本库前先算体积：几个小脚本可以忽略；将来若要放二进制包（压缩包/可执行文件），考虑对象存储或另建仓库，别让 git 历史无限长。</li>
<li>Windows 下提交任何脚本前，先确认执行位没有丢。</li>
<li>发布闭环固定为：构建 → 部署 → 清缓存 → <strong>回读断言</strong>，缺一步就可能误报成功。</li>
<li>动手前先把回滚路径写下来（旧容器与数据留几周），改反向代理前备份数据库行与生成的配置文件。</li>
</ul>

<h2 class="relative group">六、碎碎念
    <div id="六碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这次迁移之所以能在一天内做完，是因为站点足够小：4 篇文章、12 条 URL。规模再大一点，任何&quot;迁移&quot;都会变成一场项目。过程中真正花时间的不是搬家本身，而是那十个坑——它们几乎都跟&quot;两层抽象叠在一起&quot;有关：容器里看权限、容器外看符号链接、反代后面看协议、CDN 前面看缓存、主题里面看样式表产物。排错时先把这条链拆开，逐层确认身份，比猜配置快得多。</p>
<p>另一件值得记住的事：旧平台上那行无条件加载 764 KB 脚本的代码，算下来比换成静态站带来的服务端收益还大。<strong>主题里一行代码的技术债，可能比平台选择本身更值钱</strong>——迁移前后都值得先把这行找出来。</p>
]]></content:encoded>
    </item>
    <item>
      <title>LTSC 精简版大坑与排错全记录</title>
      <link>https://slop.lun.sh/ltsc-lite-pitfalls-reinstall/</link>
      <pubDate>Wed, 02 Sep 2026 15:34:36 +0800</pubDate>
      <guid isPermaLink="false">6a98420c4eeac90001a903be</guid>
      <category>Windows</category>
      <category>排错记录</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>一台&quot;看起来一切正常&quot;的 Windows 11 IoT 企业版 LTSC 2024（24H2）机器，从一次普通的 Windows 更新失败开始，走过七轮覆盖安装、数十条命令、三次多模型会诊，最后不得不格盘重装。这篇回顾把整个历程的病症、误判、实验和最终结论，用平实的语言讲清楚。</p>
</blockquote><hr>

<h2 class="relative group">一、缘起：一个&quot;普通&quot;的更新失败
    <div id="一缘起一个普通的更新失败" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e7%bc%98%e8%b5%b7%e4%b8%80%e4%b8%aa%e6%99%ae%e9%80%9a%e7%9a%84%e6%9b%b4%e6%96%b0%e5%a4%b1%e8%b4%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>某天系统提示：</p>
<blockquote><p>2026-08 安全更新 (KB5121003) 安装错误 - 0x800f0991，并要求持续安装 Windows 以修复系统文件和组件。</p>
</blockquote><p>点&quot;修复&quot;按钮，没用。重启，没用。错误码永远挂在 Windows 更新页面上。</p>
<p>机器是 <strong>Windows 11 IoT 企业版 LTSC 2024（Build 26100.6972）</strong>——注意，这个系统是&quot;精简/优化版&quot;LTSC 镜像装出来的，不是微软官方原版。当时谁也没想到，这个出身会在半年后酿成一场七连败的战役。</p>

<h2 class="relative group">二、病灶的真相：组件库与磁盘文件脱节
    <div id="二病灶的真相组件库与磁盘文件脱节" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e7%97%85%e7%81%b6%e7%9a%84%e7%9c%9f%e7%9b%b8%e7%bb%84%e4%bb%b6%e5%ba%93%e4%b8%8e%e7%a3%81%e7%9b%98%e6%96%87%e4%bb%b6%e8%84%b1%e8%8a%82" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>第一轮诊断就发现了诡异之处：<strong>系统声称装了 7900/8457/8972/9156 几个时代的组件，但关键系统文件（ntoskrnl、ntdll、DismCore）各自停留在不同版本</strong>，混装了四个时代的东西。实证结果是：UBR 停留在 6972，说明 2025 年 10 月之后再没有一次更新真正完整落地过。</p>
<p>也就是说：这台机器从装好的那天起，就在&quot;带病运行&quot;。能开机、能上网、能打游戏，但<strong>系统内部的账本（组件库记录）和仓库（磁盘文件）早就对不上了</strong>。</p>
<p>这是精简版 LTSC 的第一个大坑：<strong>&ldquo;能正常用&quot;不等于&quot;系统健康&rdquo;</strong>。精简优化镜像为了体积删掉了大量组件，删的时候如果动到了组件库的元数据（而非仅仅卸载应用），系统不会当场报错，而是把病根埋在半年后你第一次想打补丁的那一天。</p>

<h2 class="relative group">三、常规手段全灭（Stage 1：患者尝试自救）
    <div id="三常规手段全灭stage-1患者尝试自救" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e5%b8%b8%e8%a7%84%e6%89%8b%e6%ae%b5%e5%85%a8%e7%81%adstage-1%e6%82%a3%e8%80%85%e5%b0%9d%e8%af%95%e8%87%aa%e6%95%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我们按教科书顺序试了一遍，全部失败：</p>
<table>
	<thead>
			<tr>
					<th>手段</th>
					<th>结果</th>
					<th>说明了什么</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>清空 WinSxS 缓存、强制重下 7.2GB 全新载荷</td>
					<td>安装仍 0x800f0991（PSFX_E_MISSING_PAYLOAD_FILE）</td>
					<td>增量载荷结构不完整，WinSxS 载荷子树缺文件</td>
			</tr>
			<tr>
					<td>微软官方目录下载基线包+增量包（共 5.6GB）手动装</td>
					<td>基线包秒挂 0x80070228（=552）</td>
					<td>本机服务栈解析不了 MSWIM/PSF 新包格式——<strong>DismCore 太老了</strong></td>
			</tr>
			<tr>
					<td><code>DISM /RestoreHealth</code></td>
					<td>&ldquo;在任何位置都找不到修复内容&rdquo;</td>
					<td>更新源对这台机器的结构残缺，扫不出可修项</td>
			</tr>
			<tr>
					<td>设置→恢复→&ldquo;使用 Windows 更新修复问题&rdquo;</td>
					<td>&ldquo;找不到 Windows 的修复版本，请稍后重试&rdquo;</td>
					<td>系统自带的就地修复也识别不了自己的状态</td>
			</tr>
			<tr>
					<td><code>sfc /scannow</code></td>
					<td>扫描到 26% 中断，CBS 无任何记录</td>
					<td>连完整性校验器都跑不完，服务子系统病入膏肓</td>
			</tr>
	</tbody>
</table>
<p><strong>这一阶段的关键结论</strong>：这不是一次&quot;更新失败&quot;，而是<strong>服务子系统整体坏死</strong>——它不认识新格式的包、读不懂自己的账本、连修复工具都跑不完。传统偏方（清缓存、DISM、sfc、疑难解答）对这种病全部无效。</p>

<h2 class="relative group">四、外科手术之路：slipstream 为什么数学上不成立（Stage 2）
    <div id="四外科手术之路slipstream-为什么数学上不成立stage-2" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e5%a4%96%e7%a7%91%e6%89%8b%e6%9c%af%e4%b9%8b%e8%b7%afslipstream-%e4%b8%ba%e4%bb%80%e4%b9%88%e6%95%b0%e5%ad%a6%e4%b8%8a%e4%b8%8d%e6%88%90%e7%ab%8bstage-2" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>既然本机服务栈是坏的，那就绕开它——自己做一个新镜像来覆盖安装？这条路我们走得最远，也最有教育意义：</p>
<ol>
<li><strong>uupdump 没有 LTSC</strong>：这个著名的镜像生成站只有零售 SKU，微软把 LTSC 从 UUP 公开通道里排除了，做不了。</li>
<li><strong>官方评估 ISO 比你机器还旧</strong>：微软官方 LTSC 2024 评估镜像还是 26100.1742（GA 版本），比机器上的 6972 还老，不能用于升级。</li>
<li><strong>WIM 挂载注入失败</strong>（0xc1510111）：本机挂载驱动也是坏的。</li>
<li><strong>解包→注入→重新打包</strong>（apply/add-package/capture）：解包成功，但注入基线包依然 552——宿主的 DismCore（老）不认新格式。</li>
<li><strong>装上 ADK 现代 DISM 再注入</strong>：能解析 MSWIM 了，甚至成功装入了 SSU（服务栈更新），但 SSU+LCU 必须处于<strong>同一个服务会话</strong>，否则报 1726（RPC）/ schema.dat 拒绝访问。</li>
<li><strong>最后一击：检查点（Checkpoint）机制</strong>。2024 年后的累积更新是&quot;检查点基线 + 增量&quot;结构，9168 的增量基于 8972 基线，而对 GA（1742）镜像意味着要<strong>连锁安装 8 个月的检查点更新</strong>——离线注入在数学上不成立。</li>
</ol>
<p><strong>结论</strong>：手工集成镜像这条路，被微软的更新架构（MSWIM/PSF 格式 + 检查点模型 + 服务会话绑定）彻底封死。这也是普通用户&quot;做个新镜像升上去&quot;的普遍误区——<strong>在 2024 年之后的系统上，这条路已经走不通了</strong>。</p>

<h2 class="relative group">五、覆盖安装七连败（Stage 3：换镜像强攻）
    <div id="五覆盖安装七连败stage-3换镜像强攻" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e8%a6%86%e7%9b%96%e5%ae%89%e8%a3%85%e4%b8%83%e8%bf%9e%e8%b4%a5stage-3%e6%8d%a2%e9%95%9c%e5%83%8f%e5%bc%ba%e6%94%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>拿到第三方集成镜像（一个号称 26100.9168 的 25H2 镜像，实则 26200），开始覆盖安装。七次，七次失败，全部同一个面错误：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">无法安装 Windows 11
</span></span><span class="line"><span class="cl">0xC1900101 - 0x20017
</span></span><span class="line"><span class="cl">在启动(SAFE_OS)操作阶段安装失败</span></span></code></pre></div></div>
<p><strong>0xC1900101 是著名的&quot;回滚错误码&quot;</strong>，屏幕上写&quot;驱动错误&quot;，但 minidump 里全是半年前的旧文件——<strong>SAFE_OS 阶段根本没有真实内核崩溃，屏幕提示是回滚状态机的误报</strong>。真正的死因一次比一次挖得深：</p>
<ul>
<li><strong>第 1 次</strong>：卡巴斯基。迁移阶段写注册表 <code>HKCU\Software\KasperskyLab</code> 被拒（0x00000005）——杀软的自保护驱动拦截了系统迁移。卸载卡巴后，死因变了。</li>
<li><strong>第 2 次</strong>：PITR.dll 缺失。收尾阶段移除回滚快照时找不到 <code>C:\Windows\System32\OOBE\PITR.dll</code>（组件库里有，System32 副本丢了——精简版后遗症）。从 WinSxS 补回后，死因又变了。</li>
<li><strong>第 3~7 次</strong>：<code>SPDeserializeOperations: saw unrecognized object header '0x0'</code>。<strong>操作队列反序列化崩溃</strong>——这是核心死因，也是最终判决书。</li>
</ul>
<p>为了排除嫌疑，我们做了这些干净利落的实验：</p>
<table>
	<thead>
			<tr>
					<th>实验</th>
					<th>结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>清空 <code>$WINDOWS.~BT</code> 残留 + VSS 快照后重试</td>
					<td>仍 0x0（排除残留污染）</td>
			</tr>
			<tr>
					<td>补回 UnBCL.dll（序列化运行时，System32 缺失）</td>
					<td>仍 0x0（排除缺文件）</td>
			</tr>
			<tr>
					<td>换同分支 26100.9168 介质（24H2→24H2）</td>
					<td>仍 0x0（排除跨分支协议）</td>
			</tr>
			<tr>
					<td>拔掉全部外接硬盘重试</td>
					<td>operations.dat 内容变了（哈希不同）但<strong>失败点纹丝不动</strong>（排除外接介质）</td>
			</tr>
			<tr>
					<td>检查队列文件本体</td>
					<td>225KB、头部标准、无截断——<strong>文件是好的，是解析器认不出里面的对象类型</strong></td>
			</tr>
	</tbody>
</table>
<p><strong>最终归因</strong>：旧系统<strong>注册表层的 setup 组件类型注册元数据</strong>已经坏死——序列化端写出的对象，反序列化端不认。这不是缺 DLL、不是残留、不是介质问题，是&quot;账本本身烂了&quot;，任何文件层面的修复都无效。</p>

<h2 class="relative group">六、7 次失败之后的决策：为什么只能格盘
    <div id="六7-次失败之后的决策为什么只能格盘" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad7-%e6%ac%a1%e5%a4%b1%e8%b4%a5%e4%b9%8b%e5%90%8e%e7%9a%84%e5%86%b3%e7%ad%96%e4%b8%ba%e4%bb%80%e4%b9%88%e5%8f%aa%e8%83%bd%e6%a0%bc%e7%9b%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把决策过程说清楚，这件事才算讲完：</p>
<ol>
<li><strong>非破坏手段穷尽</strong>：清理、补文件、换介质、拔设备、多模型会诊（GPT/Kimi/Gemini/DeepSeek/Claude 三轮回诊全部指向同一个结论）。剩余方案胜算全部 &lt;5%。</li>
<li><strong>止损线</strong>：同一错误点出现三次以上且每次实验变量被严格排除，就必须停止&quot;再试一次&quot;的冲动。第七次拔盘实验是<strong>最后一个被允许的实验</strong>（因为它从未被验证过），失败后即无条件转重装。</li>
<li><strong>&ldquo;安全网&quot;是幻觉</strong>：覆盖安装的所谓&quot;保留系统回退&quot;在这个场景里毫无意义——旧系统本身就是病，回退回去等于回到病里。</li>
<li><strong>重装成本其实很低</strong>：备份全部个人数据（robocopy 三小时）→ 格式化 C 盘 → 全新安装 26100.9168（45 分钟）→ 数据归位。干净系统激活自动延续（OEM 数字授权），Windows 更新第一晚就自动装上了新补丁。</li>
</ol>
<p><strong>关键认知</strong>：7 次失败换来的不是&quot;再试一次&rdquo;，而是&quot;确信重装是唯一解&quot;。<strong>排错的价值在于把不确定性清零，而不是把可能性耗尽。</strong></p>

<h2 class="relative group">七、LTSC 精简版能离谱到什么程度（教训清单）
    <div id="七ltsc-精简版能离谱到什么程度教训清单" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%83ltsc-%e7%b2%be%e7%ae%80%e7%89%88%e8%83%bd%e7%a6%bb%e8%b0%b1%e5%88%b0%e4%bb%80%e4%b9%88%e7%a8%8b%e5%ba%a6%e6%95%99%e8%ae%ad%e6%b8%85%e5%8d%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这台机器的病历拿出来，每一行都是一条反例：</p>
<ul>
<li><strong>组件库记录与磁盘文件脱节</strong>：声称装了 4 个时代的组件，实测系统文件混装 4 个时代的版本，而<strong>没有任何报错</strong>。</li>
<li><strong>System32 文件黑洞</strong>：PITR.dll、UnBCL.dll 说没就没——精简工具删除组件时连核心运行时都敢动。</li>
<li><strong>DismCore 老到不认新包</strong>：缩写为 552 的怪错误，本质是旧服务栈解析不了 2024 年后新格式的更新包。</li>
<li><strong>升级时的连环爆</strong>：卡巴拦截（外部因素）→ PITR 缺文件（文件被删）→ 序列化坏死（元数据损坏），一次比一次深，像剥洋葱。</li>
<li><strong>第三方镜像的坑</strong>：&ldquo;原版集成&quot;镜像把 26100.9168 集成成了 26200（跨分支！）；wiminfo 的版本元数据被抹掉；号称&quot;完整+适量精简&quot;的镜像里<strong>没有 IoT 版索引</strong>（只有 EnterpriseS，装不上你机器的 SKU）；7.6GB 的&quot;无驱版&quot;连版本号都查不到。</li>
</ul>
<p><strong>给后来者的三句话</strong>： 1. <strong>LTSC/精简镜像装完先打一次更新验证</strong>：机器能开机能上网，不代表服务栈健康。装好系统第一件事是打满更新，如果更新失败，趁数据少赶紧重装。 2. <strong>升级失败看日志，不看屏幕</strong>：0xC1900101 的屏幕文案是&quot;驱动错误&rdquo;，真因全在 <code>C:\$WINDOWS.~BT\Sources\Panther\setuperr.log</code> 和 SetupDiag 里。 3. <strong>错误码是一个阶梯</strong>：0x800f0991（载荷缺失）、0x80070228/552（服务栈不认包格式）、0xC1900401（检查点基线缺失）、SPDeserializeOperations 0x0（元数据/序列化坏死）——越靠后的错误越接近&quot;没救了&quot;的判决。</p>

<h2 class="relative group">八、最终的最终
    <div id="八最终的最终" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ab%e6%9c%80%e7%bb%88%e7%9a%84%e6%9c%80%e7%bb%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>机器现在是：<strong>Windows 11 IoT 企业版 LTSC 2024，26100.9168，已激活，更新畅通</strong>。</li>
<li>数据：桌面/文档/音乐/邮件/浏览器/微信文件，<strong>100% 回归</strong>（连浏览器打开的标签页都救了回来，<a href="/brave-data-recovery/" >见第二篇</a>）。</li>
<li>教训：<strong>&ldquo;能用&quot;和&quot;健康&quot;是两回事。精简版 LTSC 用它的方式为你省了空间，而你终将在某一天用一次格盘重装偿还。</strong></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Windows 重装后 SID/所有权批量修复工具包</title>
      <link>https://slop.lun.sh/windows-sid-fix-toolkit/</link>
      <pubDate>Wed, 02 Sep 2026 15:34:35 +0800</pubDate>
      <guid isPermaLink="false">6a98420a4eeac90001a903b4</guid>
      <category>Windows</category>
      <category>工具包</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>适用场景：重装系统后（即使还用同一个用户名），文件/文件夹属性里出现&quot;未知账户&quot;， 或者旧硬盘（包括移动硬盘）上的文件提示&quot;拒绝访问&quot;。 原理：Windows 重装会生成新的机器 SID，旧文件的所有者和权限条目里记录的是旧 SID。</p>
</blockquote>
<h2 class="relative group">需要什么
    <div id="需要什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%9c%80%e8%a6%81%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>Windows 10/11，<strong>PowerShell 7 (pwsh)</strong>（没有的话管理员运行：<code>winget install Microsoft.PowerShell</code>）</li>
<li>管理员权限（运行时会弹 UAC，点&quot;是&quot;）</li>
<li>要修复的硬盘全部接好</li>
</ul>

<h2 class="relative group">使用步骤
    <div id="使用步骤" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bd%bf%e7%94%a8%e6%ad%a5%e9%aa%a4" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">1. 修复（改所有权 + 替换所有旧 SID）
    <div id="1-修复改所有权--替换所有旧-sid" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#1-%e4%bf%ae%e5%a4%8d%e6%94%b9%e6%89%80%e6%9c%89%e6%9d%83--%e6%9b%bf%e6%8d%a2%e6%89%80%e6%9c%89%e6%97%a7-sid" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>管理员 PowerShell 7 中运行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">pwsh -NoProfile -ExecutionPolicy Bypass -File fix_sids.ps1 -Roots &#34;D:;E:;F:;G:;H:;I:&#34;</span></span></code></pre></div></div>
<ul>
<li><code>-Roots</code> 换成你自己的盘符列表，分号分隔；<strong>不要包含系统盘 C:</strong>；不填则自动选择除 C: 外所有卷</li>
<li>脚本会先做一次<strong>自检</strong>（在临时目录造测试数据验证修复逻辑），通过后才开始处理真实数据</li>
<li>每块盘的处理顺序：<code>takeown /r</code> 批量改所有者 → 逐个对象替换旧 SID（所有者/组/权限条目，含从父目录继承的）</li>
<li>进度写入 <code>%TEMP%\hermes_acl\fix_sids.log</code>；跑完看日志末尾：<code>err=0 fails=0</code> 即完美</li>
</ul>

<h3 class="relative group">2. 恢复盘符（可选，重装后 Windows 会重新分配盘符）
    <div id="2-恢复盘符可选重装后-windows-会重新分配盘符" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#2-%e6%81%a2%e5%a4%8d%e7%9b%98%e7%ac%a6%e5%8f%af%e9%80%89%e9%87%8d%e8%a3%85%e5%90%8e-windows-%e4%bc%9a%e9%87%8d%e6%96%b0%e5%88%86%e9%85%8d%e7%9b%98%e7%ac%a6" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>先确认重装前各盘是什么字母（旧笔记/习惯），再编辑 <code>restore_letters.ps1</code> 里的映射表，然后管理员运行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">pwsh -NoProfile -ExecutionPolicy Bypass -File restore_letters.ps1</span></span></code></pre></div></div>

<h3 class="relative group">3. 验证（确认&quot;未知账户&quot;清零）
    <div id="3-验证确认未知账户清零" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#3-%e9%aa%8c%e8%af%81%e7%a1%ae%e8%ae%a4%e6%9c%aa%e7%9f%a5%e8%b4%a6%e6%88%b7%e6%b8%85%e9%9b%b6" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">pwsh -NoProfile -ExecutionPolicy Bypass -File acl_scan.ps1 -Roots &#34;D:;E:;F:;G:;H:;I:;C:\Users&#34;</span></span></code></pre></div></div>
<p>结果文件在 <code>%TEMP%\hermes_acl\acl_scan_result.txt</code>：<strong>bad = 0</strong> 即全部修复。 （<code>C:\Users\Public</code> 下出现 <code>S-1-5-3</code>/<code>S-1-5-4</code> 是 Windows 系统默认 SID，属正常，不是问题。）</p>

<h2 class="relative group">注意事项
    <div id="注意事项" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b3%a8%e6%84%8f%e4%ba%8b%e9%a1%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>修复期间<strong>不要关机、不要拔盘</strong>；被其它程序占用中的文件可能被跳过（日志里 <code>SET_ERR</code>），关掉程序后重跑一次即可</li>
<li>跳过 <code>$RECYCLE.BIN</code>、<code>System Volume Information</code>、<code>Recovery</code>（系统自己会重建，不用管）</li>
<li>权限级别（完全控制/修改/只读等）<strong>原样保留</strong>，只是把旧身份换成新身份；不会破坏自定义权限</li>
<li>修复结果只对&quot;这台机器&quot;有效：把盘插到另一台电脑上，那台电脑仍会显示未知（NTFS 权限按机器记）。多机共用建议额外加 <code>Authenticated Users</code> 权限</li>
<li>中途断掉也没关系：脚本幂等，重跑会继续处理剩下的</li>
</ul>

<h2 class="relative group">常见问题
    <div id="常见问题" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%b8%b8%e8%a7%81%e9%97%ae%e9%a2%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>现象</th>
					<th>处理</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>日志 <code>NOT_ELEVATED_ABORT</code></td>
					<td>没管理员权限，用管理员终端重跑</td>
			</tr>
			<tr>
					<td>自检 <code>SELFTEST_FAIL</code></td>
					<td>环境异常，先解决权限/磁盘问题，不要强行跳过</td>
			</tr>
			<tr>
					<td><code>err</code> 或 <code>fails</code> 不为 0</td>
					<td>看日志对应行；多数是被占用文件，关程序重跑</td>
			</tr>
			<tr>
					<td>没有 pwsh</td>
					<td><code>winget install Microsoft.PowerShell</code> 后重开终端</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">工具包下载
    <div id="工具包下载" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%b7%a5%e5%85%b7%e5%8c%85%e4%b8%8b%e8%bd%bd" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>三个脚本（管理员 PowerShell 7 运行，均为明文、程序员友好）：</p>
<ul>
<li><a href="/content/files/2026/09/acl_scan.ps1" >acl_scan.ps1</a> — 扫描验证（bad = 0 即修复完成）</li>
<li><a href="/content/files/2026/09/fix_sids.ps1" >fix_sids.ps1</a> — 一键修复（自检 + takeown + SID 替换）</li>
<li><a href="/content/files/2026/09/restore_letters.ps1" >restore_letters.ps1</a> — 恢复盘符映射</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Brave 数据复活记：重装系统后如何不丢标签页与扩展数据</title>
      <link>https://slop.lun.sh/brave-data-recovery/</link>
      <pubDate>Mon, 31 Aug 2026 17:35:28 +0800</pubDate>
      <guid isPermaLink="false">6a95bb60f0de240001bca9b2</guid>
      <category>Brave</category>
      <category>浏览器恢复</category>
      <dc:creator>Lun</dc:creator>
      <description></description>
      <content:encoded><![CDATA[<blockquote><p>重装 Windows 后，把浏览器数据从备份里拷回去，打开 Brave 却发现&quot;因安全原因&quot;丢了打开的标签页、固定标签页和扩展程序数据。本文基于 2026-08-26 与 2026-08-31 两轮实战（Default 完整恢复 + 另一个 Profile 扩展全丢后完整救回），讲清楚 Chrome/Brave 的数据机制、正确的备份/恢复姿势，以及所有试错后验证出的<strong>唯一正道</strong>。</p>
</blockquote><hr>

<h2 class="relative group">一、先搞懂四个机制，你就成功了一半
    <div id="一先搞懂四个机制你就成功了一半" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e5%85%88%e6%90%9e%e6%87%82%e5%9b%9b%e4%b8%aa%e6%9c%ba%e5%88%b6%e4%bd%a0%e5%b0%b1%e6%88%90%e5%8a%9f%e4%ba%86%e4%b8%80%e5%8d%8a" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">机制 1：会话文件分两套，只有一套能救你
    <div id="机制-1会话文件分两套只有一套能救你" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ba%e5%88%b6-1%e4%bc%9a%e8%af%9d%e6%96%87%e4%bb%b6%e5%88%86%e4%b8%a4%e5%a5%97%e5%8f%aa%e6%9c%89%e4%b8%80%e5%a5%97%e8%83%bd%e6%95%91%e4%bd%a0" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Chrome 系浏览器的标签页恢复依赖两类文件：</p>
<p><strong>① 活动会话文件（会无辜失踪的那套）</strong> - <code>User Data\Default\Current Session</code> / <code>Current Tabs</code> - <code>User Data\Default\Last Session</code> / <code>Last Tabs</code></p>
<p>这是&quot;进行中会话&quot;的实时快照。<strong>浏览器正常关闭时，会主动删掉它们</strong>——所以当你看到&quot;备份里没有 Session 文件&quot;，不代表没救，而是浏览器最后一次是干净退出的（这恰恰是好事）。</p>
<p><strong>② 会话恢复快照（真正救你命的那套）</strong> - <code>User Data\Default\Sessions\Tabs_1389xxxxx</code>（标签页状态，十几 MB） - <code>User Data\Default\Sessions\Session_1389xxxxx</code>（每个标签的数据） - <code>User Data\Default\Session Storage\</code>（表单、页面局部状态）</p>
<p>这套文件由&quot;会话恢复系统&quot;<strong>持续落盘</strong>，浏览器退出时不会删除，专门用于崩溃恢复和&quot;继续上次会话&quot;。<strong>备份时只要这套文件在，标签页就能救回来</strong>（含最后写盘时刻的状态）。多 profile 同理：<code>Profile N\Sessions\</code> 也在。</p>

<h3 class="relative group">机制 2：扩展注册在哪——Secure Preferences，不是 Preferences（实战修正）
    <div id="机制-2扩展注册在哪secure-preferences不是-preferences实战修正" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ba%e5%88%b6-2%e6%89%a9%e5%b1%95%e6%b3%a8%e5%86%8c%e5%9c%a8%e5%93%aasecure-preferences%e4%b8%8d%e6%98%af-preferences%e5%ae%9e%e6%88%98%e4%bf%ae%e6%ad%a3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><strong>扩展的注册（启用的扩展清单、安装时间、来源）在 <code>Secure Preferences</code> 文件的 <code>extensions.settings</code> 里，不在 <code>Preferences</code> 里。</strong> 这是最容易踩的认知误区：</p>
<table>
	<thead>
			<tr>
					<th>文件</th>
					<th>受保护？</th>
					<th>扩展注册</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Preferences</code></td>
					<td>❌ 明文、可手改</td>
					<td>只有 <code>extensions.install_signature.ids</code>（扩展 ID 的签名记录，不受 MAC 保护，<strong>可作恢复依据</strong>）</td>
			</tr>
			<tr>
					<td><code>Secure Preferences</code></td>
					<td>✅ 有 MAC 防篡改（protection.super_mac + 字段级 macs）</td>
					<td><strong><code>extensions.settings</code> —— 扩展注册真正所在</strong></td>
			</tr>
	</tbody>
</table>
<p><code>Secure Preferences</code> 的 MAC 用 <code>Local State</code> 里的 os_crypt 密钥计算。<strong>手改 Secure Preferences 任何一个受保护字段 → MAC 失效 → 浏览器丢弃整个受保护区（扩展注册清空，只剩内置扩展）→ 写回一份&quot;干净&quot;的文件。</strong></p>

<h3 class="relative group">机制 3：&ldquo;安全原因&quot;重置的真实触发条件（实战修正）
    <div id="机制-3安全原因重置的真实触发条件实战修正" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ba%e5%88%b6-3%e5%ae%89%e5%85%a8%e5%8e%9f%e5%9b%a0%e9%87%8d%e7%bd%ae%e7%9a%84%e7%9c%9f%e5%ae%9e%e8%a7%a6%e5%8f%91%e6%9d%a1%e4%bb%b6%e5%ae%9e%e6%88%98%e4%bf%ae%e6%ad%a3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>旧认知：&ldquo;外部修改了 Preferences 文件 → 重置&rdquo;。<strong>实测真正的触发条件是：<code>Extensions\</code> 目录与 Secure Preferences 里的注册不一致</strong>：</p>
<ul>
<li><strong>目录里有&quot;未注册&quot;的扩展本体</strong> = 疑似外部注入 → 整体重置（清空注册 + <strong>删除整个 Extensions 目录内容</strong>）</li>
<li><strong>注册了但本体目录缺失</strong> = 疑似被破坏 → 同上</li>
</ul>
<p>也就是说：<strong>把扩展本体拷进 <code>Extensions\</code> 目录这个&quot;标准操作&rdquo;，在注册已被清空的状态下反而会触发重置并删掉你刚拷进去的东西。</strong> 这是本次目标 Profile 恢复中反复被重置删目录的根因。</p>
<p><strong>它重置什么、保留什么：</strong></p>
<table>
	<thead>
			<tr>
					<th>被重置/删除</th>
					<th>被保留</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>extensions.settings</code> 注册（只剩内置 PDF/Brave/应用商店）</td>
					<td><strong>书签、历史、密码、Cookie</strong>（数据库文件，不受影响）</td>
			</tr>
			<tr>
					<td><code>Extensions\</code> 目录全部内容（扩展本体）</td>
					<td><strong><code>Local Extension Settings\</code></strong>（多数情况保留）</td>
			</tr>
			<tr>
					<td>部分扩展的设置项</td>
					<td><strong><code>IndexedDB\</code></strong>（实测数据完好）</td>
			</tr>
			<tr>
					<td></td>
					<td><code>Sessions\</code> / <code>Session Storage\</code>（标签页快照）</td>
			</tr>
	</tbody>
</table>
<blockquote><p>⚠️ 注意：<strong>扩展被&quot;卸载&quot;时（比如从 chrome://extensions 移除，或强制安装策略被移除）会连带删除 <code>Local Extension Settings</code> 和 <code>IndexedDB</code> 里该扩展的数据</strong>——这与&quot;安全重置&quot;不同，重置通常保留数据目录，卸载会删。所以<strong>卸载类操作前务必先备份数据目录</strong>。</p>
</blockquote>
<h3 class="relative group">机制 4：加密链——Local State 的密钥，重装后就换掉了
    <div id="机制-4加密链local-state-的密钥重装后就换掉了" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ba%e5%88%b6-4%e5%8a%a0%e5%af%86%e9%93%belocal-state-%e7%9a%84%e5%af%86%e9%92%a5%e9%87%8d%e8%a3%85%e5%90%8e%e5%b0%b1%e6%8d%a2%e6%8e%89%e4%ba%86" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><code>User Data\Local State</code> 里保存着加密主密钥（<code>os_crypt.encrypted_key</code>，DPAPI 加密）。它保护： - <code>Secure Preferences</code> 的 MAC（防篡改校验） - 密码库 <code>Login Data</code>、Cookie 的解密</p>
<p><strong>重装 Windows 后</strong>：DPAPI 上下文（用户 master key）随旧系统消失 → 备份里的 Local State 的 encrypted_key <strong>解不开</strong> → 浏览器静默生成新密钥并重写 Local State。后果：</p>
<ul>
<li>备份里所有 <code>Secure Preferences</code>（旧密钥 MAC）→ <strong>全部校验失败</strong> → 首次启动各 profile 的扩展注册被逐 profile 重置（哪个 profile 先被打开就先重置哪个）</li>
<li>备份里的密码库/Cookie（旧密钥加密）→ 无法解密，视为空库（新密钥下重新登录）</li>
</ul>
<p><strong>所以：重装后&quot;从备份整体回拷 User Data&quot;必然经历一次&quot;安全原因&quot;重置，扩展注册必丢。这不是操作失误，是机制使然。</strong> 书签/历史/标签页（不加密的 SQLite/快照）不受影响。</p>
<hr>

<h2 class="relative group">二、备份的正确姿势（事前，最重要）
    <div id="二备份的正确姿势事前最重要" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%8c%e5%a4%87%e4%bb%bd%e7%9a%84%e6%ad%a3%e7%a1%ae%e5%a7%bf%e5%8a%bf%e4%ba%8b%e5%89%8d%e6%9c%80%e9%87%8d%e8%a6%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<ol>
<li><strong>完全退出浏览器</strong>（关闭所有窗口 + 托盘图标退出，确认 <code>brave.exe</code> 无进程）。运行中备份会漏掉锁定的文件。</li>
<li><strong>整体拷贝 <code>User Data</code> 目录</strong>（9~10GB），绝不只拷 <code>Default</code>： <code>%LOCALAPPDATA%\BraveSoftware\Brave-Browser\User Data\ ← 整个目录</code> 里面包含：<code>Local State</code>、<code>Default\</code> 及所有 <code>Profile N\</code>、Sessions 快照、扩展本体与数据。</li>
<li><strong>校验</strong>：拷贝后对比文件数与总大小（差 5% 以内正常，差 20% 以上有问题）。</li>
<li><strong>不要修改任何文件</strong>，也<strong>不要做&quot;精简&quot;</strong>（例如裁剪备份时把 Brave 目录删掉——本次事故的伏笔就是裁剪备份时以为&quot;已恢复&quot;而删了 Brave 目录，事后只剩 原始备份包 里的副本）。</li>
</ol>
<blockquote><p>一句话：<strong>关浏览器、全量拷 User Data、一个字节都不要改、备份别裁剪。</strong></p>
</blockquote><hr>

<h2 class="relative group">三、恢复的正确姿势（事后，实战验证版）
    <div id="三恢复的正确姿势事后实战验证版" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e6%81%a2%e5%a4%8d%e7%9a%84%e6%ad%a3%e7%a1%ae%e5%a7%bf%e5%8a%bf%e4%ba%8b%e5%90%8e%e5%ae%9e%e6%88%98%e9%aa%8c%e8%af%81%e7%89%88" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">第 0 步：认清现实
    <div id="第-0-步认清现实" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac-0-%e6%ad%a5%e8%ae%a4%e6%b8%85%e7%8e%b0%e5%ae%9e" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>重装系统后从备份回拷，<strong>扩展注册必丢</strong>（机制 4）。目标不是&quot;整体回拷免重置&quot;，而是：</p>
<ol>
<li><strong>恢复数据</strong>（LES / IndexedDB / Local Storage / Sessions——都不受 MAC 保护，可从备份任意合并）</li>
<li><strong>重新安装扩展本体</strong>（唯一正道：Chrome 商店，见下）</li>
</ol>

<h3 class="relative group">第 1 步：恢复数据目录（任意合并，安全）
    <div id="第-1-步恢复数据目录任意合并安全" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac-1-%e6%ad%a5%e6%81%a2%e5%a4%8d%e6%95%b0%e6%8d%ae%e7%9b%ae%e5%bd%95%e4%bb%bb%e6%84%8f%e5%90%88%e5%b9%b6%e5%ae%89%e5%85%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>关闭浏览器后，从备份把缺失的数据目录拷回（robocopy 合并，只补不删）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Local Extension Settings\   ← 扩展设置/chrome.storage（重置会删部分，卸载会删全部）
</span></span><span class="line"><span class="cl">IndexedDB\                  ← 扩展数据库（站点/脚本数据等）
</span></span><span class="line"><span class="cl">Local Storage\              ← 站点本地存储
</span></span><span class="line"><span class="cl">Sessions\ + Session Storage\ ← 标签页快照（若当前已恢复会话可跳过）</span></span></code></pre></div></div>
<p><strong>不要碰</strong> <code>Secure Preferences</code>（MAC 保护区）和 <code>Extensions\</code>（放未注册本体 = 触发重置）。</p>

<h3 class="relative group">第 2 步：商店重装扩展（唯一正道）
    <div id="第-2-步商店重装扩展唯一正道" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac-2-%e6%ad%a5%e5%95%86%e5%ba%97%e9%87%8d%e8%a3%85%e6%89%a9%e5%b1%95%e5%94%af%e4%b8%80%e6%ad%a3%e9%81%93" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><strong>扩展 ID 由 manifest 里的 key（公钥）派生</strong>（SHA256(key) 前 16 字节，每字节高低 nibble 映射 a-p，共 32 字符）。商店扩展的 ID 是稳定公开的 → <strong>从商店重新安装，ID 必然与原来相同 → 已恢复的 <code>Local Extension Settings</code> / <code>IndexedDB</code> 数据自动挂载</strong>（Chrome 按 ID 找数据目录）。</p>
<p>操作（每个扩展）： 1. 打开商店直链 <code>https://chromewebstore.google.com/detail/&lt;扩展ID&gt;</code> 2. 点「添加到 Brave」（或&quot;添加至 Chrome&quot;） 3. 点 Brave 弹出的「添加扩展程序」确认框 ← <strong>这一步只能手动点，任何自动化都点不了浏览器 UI 弹窗</strong> 4. 验证：打开扩展面板/设置页，<strong>没有欢迎页 = 数据完整挂载</strong></p>
<blockquote><p>商店安装 = 正常扩展（自动更新、无特殊状态）。ID 相同的商店版会直接挂载旧数据目录里的数据。</p>
</blockquote>
<h3 class="relative group">第 3 步：验证
    <div id="第-3-步验证" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac-3-%e6%ad%a5%e9%aa%8c%e8%af%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<ul>
<li><code>chrome://extensions</code> 列表里扩展齐全、无&quot;已损坏&quot;标记</li>
<li>打开每个扩展的设置页：配置/脚本/数据在（无欢迎页、无空白）</li>
<li>固定标签页：手动重新固定（防篡改保护区内，不值得硬碰）</li>
</ul>
<hr>

<h2 class="relative group">四、本次实战的完整踩坑记录（目标 Profile 恢复全历程）
    <div id="四本次实战的完整踩坑记录目标-profile-恢复全历程" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9b%e6%9c%ac%e6%ac%a1%e5%ae%9e%e6%88%98%e7%9a%84%e5%ae%8c%e6%95%b4%e8%b8%a9%e5%9d%91%e8%ae%b0%e5%bd%95%e7%9b%ae%e6%a0%87-profile-%e6%81%a2%e5%a4%8d%e5%85%a8%e5%8e%86%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>背景</strong>：8/28 恢复时 其余三个 profile 的扩展本体拷回了，目标 Profile 的 <code>Extensions\</code> 漏拷（空目录）。8/31 第一次打开目标 Profile → 注册 10 条 vs 本体 0 个 → 触发重置 → 注册清空（只剩 2 个内置）+ 删空目录。用户看到&quot;扩展记录消失&quot;。</p>
<p><strong>以下方案全部实测失败，勿再尝试：</strong></p>
<table>
	<thead>
			<tr>
					<th>方案</th>
					<th>失败原因</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>把本体拷回 <code>Extensions\</code> 目录</td>
					<td>目录里有未注册扩展 = 注入嫌疑 → <strong>整体重置并删掉刚拷入的目录</strong></td>
			</tr>
			<tr>
					<td>从备份直接覆盖 <code>Secure Preferences</code>（含完整注册）</td>
					<td>备份是旧系统密钥签的 MAC → 校验失败 → 注册被清（连内置之外全丢）</td>
			</tr>
			<tr>
					<td>手改 <code>Secure Preferences</code>（删掉本体缺失的注册条目再写回）</td>
					<td>同 MAC 问题：手改 → MAC 失效 → 被丢弃</td>
			</tr>
			<tr>
					<td><code>--load-extension</code> 命令行加载</td>
					<td><strong>会话级</strong>：写入的注册 state 字段缺失（loc=8 但 state=None），重启后运行时即失效，扩展全部消失</td>
			</tr>
			<tr>
					<td>chrome://extensions 里&quot;加载已解压的扩展程序&quot;</td>
					<td>可行但：① 文件选择器是原生对话框，CDP 无法自动化；② UNPACKED 状态带&quot;开发者模式&quot;标签、不自动更新；③ 商店版才是正常状态</td>
			</tr>
			<tr>
					<td><code>ExtensionInstallForcelist</code> 注册表策略静默安装</td>
					<td><strong>装上了，但移除策略 = 卸载扩展 + 连带删除该扩展的 LES/IndexedDB 数据目录</strong>（数据删了才发现）</td>
			</tr>
	</tbody>
</table>
<p><strong>最终成功路径</strong>：数据目录从备份恢复（LES + IndexedDB）→ 用户手动商店安装 5 个扩展 → 数据自动挂载（扩展数据完整回归）。<strong>全程唯一需要用户动手的就是点确认弹窗。</strong></p>
<p><strong>其他实战细节：</strong> - 固实/非固实压缩包 提取单文件用 <code>UnRAR.exe e -y -idq &lt;rar&gt; &lt;精确路径&gt; &lt;dest&gt;\</code>，python subprocess 传参避免 shell 转义地狱；路径前缀匹配必须单反斜杠 - 验证运行时扩展状态用 CDP：<code>brave.exe --profile-directory=&quot;Profile N&quot; --remote-debugging-port=9333 --remote-allow-origins=*</code>，然后在 <code>chrome://extensions</code> 页面上下文执行 <code>chrome.management.getAll()</code>（<strong>商店页面等普通页面没有该权限，返回空不代表没装</strong>） - <code>install_signature.ids</code>（Preferences 里，不受保护）是扩展 ID 的可靠参照，可用于核对应恢复哪些扩展</p>
<hr>

<h2 class="relative group">五、常见的错误做法（避雷表·扩充版）
    <div id="五常见的错误做法避雷表扩充版" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%ba%94%e5%b8%b8%e8%a7%81%e7%9a%84%e9%94%99%e8%af%af%e5%81%9a%e6%b3%95%e9%81%bf%e9%9b%b7%e8%a1%a8%e6%89%a9%e5%85%85%e7%89%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>错误</th>
					<th>后果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>只拷 <code>Default</code> 不拷 <code>Local State</code></td>
					<td>加密链断裂 → 密码/扩展数据全军覆没</td>
			</tr>
			<tr>
					<td>浏览器开着时备份/恢复</td>
					<td>锁定的文件没拷全 → 恢复后档案残缺</td>
			</tr>
			<tr>
					<td>手改 <code>Preferences</code> 想&quot;开启恢复&quot;</td>
					<td>Preferences 本身可改，但改动不会生效在受保护区；真正禁区是 Secure Preferences</td>
			</tr>
			<tr>
					<td>手改 <code>Secure Preferences</code></td>
					<td>MAC 失效 → 注册被清（只剩内置）</td>
			</tr>
			<tr>
					<td>把扩展本体拷进 <code>Extensions\</code>（注册已丢时）</td>
					<td>触发&quot;注入&quot;重置 → 目录被删</td>
			</tr>
			<tr>
					<td>用 <code>--load-extension</code> 长期恢复</td>
					<td>重启即失效</td>
			</tr>
			<tr>
					<td>用 Forcelist 策略装完就移除</td>
					<td>扩展 + 数据目录一起被卸载删除</td>
			</tr>
			<tr>
					<td>恢复后立即删除备份</td>
					<td>万一重置/卸载删了数据，没有后悔药（<strong>先确认扩展无欢迎页再删</strong>）</td>
			</tr>
			<tr>
					<td>备份裁剪时删掉浏览器目录</td>
					<td>事后想恢复没有&quot;原始快照&quot;可用（保留原始 RAR）</td>
			</tr>
	</tbody>
</table>
<hr>

<h2 class="relative group">六、一图流速查
    <div id="六一图流速查" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%ad%e4%b8%80%e5%9b%be%e6%b5%81%e9%80%9f%e6%9f%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">备份：关浏览器 → 整体拷 User Data → 校验大小文件数 → 存档（别裁剪）
</span></span><span class="line"><span class="cl">重装后恢复：
</span></span><span class="line"><span class="cl">  1) 关浏览器 → 合并拷回数据目录（LES/IndexedDB/Local Storage/Sessions）
</span></span><span class="line"><span class="cl">     ⚠️ 不要碰 Secure Preferences 和 Extensions\
</span></span><span class="line"><span class="cl">  2) 打开浏览器（扩展注册已被重置，只剩内置 —— 正常现象）
</span></span><span class="line"><span class="cl">  3) 逐个商店重装扩展（https://chromewebstore.google.com/detail/&lt;扩展ID&gt;）
</span></span><span class="line"><span class="cl">     → 点「添加到 Brave」→ 点确认弹窗「添加扩展程序」（必须手动）
</span></span><span class="line"><span class="cl">  4) 打开扩展设置页：无欢迎页、数据在 = 完成
</span></span><span class="line"><span class="cl">确认一切正常 → 才删除备份</span></span></code></pre></div></div>
<hr>

<h2 class="relative group">七、最后的碎碎念
    <div id="七最后的碎碎念" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%83%e6%9c%80%e5%90%8e%e7%9a%84%e7%a2%8e%e7%a2%8e%e5%bf%b5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>浏览器数据是最珍贵的数字资产：书签是索引，密码是钥匙，标签页是&quot;我还没读完的世界&quot;，扩展是生产力。重装系统前花十分钟按上面的姿势备份，重装后就不会经历&quot;安全原因&quot;四个字带来的心跳骤停。</p>
<p><strong>记住四件事：Local State 必须一起拷；Secure Preferences 一个字节都别改；扩展注册必丢是机制使然，数据目录才是要救的；扩展本体用商店重装（ID 不变 → 数据自动挂载），确认弹窗手动点。</strong></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
