<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>QiguaiのBlog</title><description>奇怪的博客</description><link>http://qiguai.net/</link><templateTheme>Qiguai</templateTheme><templateThemeVersion>V2.7.0</templateThemeVersion><templateThemeUrl>https://github.com/CuteLeaf/Qiguai</templateThemeUrl><lastBuildDate>2026年9月1日 18:05:36</lastBuildDate><item><title>博客开发日记</title><link>http://qiguai.net/posts/others/others-blog-dev-notes-2026/</link><guid isPermaLink="true">http://qiguai.net/posts/others/others-blog-dev-notes-2026/</guid><description>从零搭建 Astro 博客的完整记录：技术选型、后台管理系统、友链页面设计、站内自助申请表单、构建系统防卡死优化、域名绑定与 nginx 静态托管、CSRF 移除决策，以及 debug 才发现的坑。</description><pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<p>很早之前就想做个博客，但是一直没费功夫 一直在考虑是wordpress，typecho还是别的。但是最后还是选了astro。本站大部分部件是照搬了明崽大佬的魔改firefly模板，同时增加了一些自己的灵感 再次感谢。（）</p>
<p>选 Astro 的理由很简单：<strong>Islands Architecture（岛屿架构）</strong> 默认零 JS，只在需要交互的组件注水，对博客这种内容为主的站点特别友好。再加上原生支持 Svelte/Vue/React 组件，挺灵活的。</p>
<p>这篇笔记把从零搭站过程中踩过的坑、做过的设计决策都记下来，既是对自己折腾过程的复盘，也希望能给同样想入坑 Astro 的朋友一点参考。（同时本篇写作手法通过了豆包同志 因为懒得做md内容排班格式）</p>
<p>涉及的技术栈：<strong>Astro + Svelte 5 + TypeScript</strong> 的前台，<strong>Fastify + SQLite</strong> 的后台管理服务。</p>
<h2>一、后台管理系统：双 SQLite 驱动降维打击 🛠️</h2>
<p>博客前台是静态的，但后台管理总得有个服务端。我选了 Fastify 起一个轻量 API 服务，数据库用 SQLite 单文件，部署简单。</p>
<p>问题来了——<code>better-sqlite3</code> 这个原生模块，在不同环境下各有各的坑：</p>
<ul>
<li><strong>Windows 本地开发</strong>：装完 Visual Studio Build Tools，依然可能缺 MSVC VC++ 工具集，<code>better-sqlite3</code> 编译直接失败</li>
<li><strong>Linux 生产服务器</strong>：GLIBC 版本太老（比如找不到 <code>GLIBC_2.29</code>），prebuild-install 直接跪了</li>
</ul>
<h3>双驱动降级策略</h3>
<p>解决方案是在数据库访问层做了一层抽象，<strong>优先用原生驱动吃性能，挂了就回退到内置模块保可用</strong>：</p>
<pre><code class="language-typescript">let driver: 'better-sqlite3' | 'node:sqlite' = 'better-sqlite3';

try {
  const BetterSqlite = require('better-sqlite3');
  const probe = new BetterSqlite(':memory:');
  probe.close();
  // 探测成功，使用 better-sqlite3（性能更好）
} catch {
  driver = 'node:sqlite';
  // 回退到 Node 22+ 内置的 node:sqlite
}
</code></pre>
<p>Node 22+ 已经内置了 <code>node:sqlite</code>（实验性），作为开发环境的兜底完全够用。这样一来，本地 Windows 开发不用装编译工具链，服务器 GLIBC 老也不怕，两个环境都能跑起来。</p>
<h3>Fastify 插件超时问题</h3>
<p>部署时还遇到一个隐蔽的坑：Fastify 默认插件超时是 10 秒，而 <code>tsx</code> 实时转译 TypeScript 在首次加载时比较慢，直接触发 <code>AVV_ERR_PLUGIN_EXEC_TIMEOUT</code>。</p>
<p>解决方案是在 Fastify 构造里关掉超时：</p>
<pre><code class="language-typescript">const app = Fastify({
  pluginTimeout: 0,  // tsx 实时转译较慢，关闭避免误杀
  // ...
});
</code></pre>
<p>同时把所有同步风格的路由注册器用 <code>asPlugin</code> 包成 async 插件，避免 Fastify 把同步函数误判成回调风格导致 Promise 永不 resolve。这个坑真的很阴间，表现是服务挂起但不出错，debug 半天才定位到。</p>
<h3>静默退出问题</h3>
<p>最诡异的是服务偶尔会以 exit code 0 静默退出，没有任何错误日志。最后的保命组合拳是：</p>
<pre><code class="language-typescript">process.on('uncaughtException', (e) =&gt; console.error('[boot] uncaughtException:', e));
process.on('unhandledRejection', (e) =&gt; console.error('[boot] unhandledRejection:', e));
const keepAlive = setInterval(() =&gt; {}, 1000);  // 防止事件循环空转退出
</code></pre>
<p><code>setInterval(() =&gt; {}, 1000)</code> 看起来很蠢，但它确实能让事件循环保持活跃，避免 Node 判断「没有待处理的任务」后主动退出。</p>
<hr />
<h2>二、一键启动脚本：Win &amp; Linux 双平台 🚀</h2>
<p>为了让部署更省心，写了两套一键启动脚本：</p>
<table>
<thead>
<tr>
<th>脚本</th>
<th>平台</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td><code>start.ps1</code></td>
<td>Windows PowerShell</td>
<td>本地开发 + 生产部署</td>
</tr>
<tr>
<td><code>start.sh</code></td>
<td>Linux Bash</td>
<td>服务器部署</td>
</tr>
</tbody></table>
<h3>核心功能</h3>
<ul>
<li><code>deploy</code> 子命令：环境检查 → 依赖安装 → 构建 → 启动，一条龙</li>
<li><code>prod</code> 模式：自动检测构建产物是否存在，缺失则先构建</li>
<li>前端 <code>astro preview</code> 默认只绑 <code>localhost</code>，<strong>必须加 <code>--host 0.0.0.0</code></strong> 才能外部访问，这是个老坑了</li>
</ul>
<p>部署到服务器后，一条命令就能把前台、后台、管理界面全拉起来，不用再手敲三串命令。</p>
<hr />
<h2>三、友链页面设计：卡片网格 + 实时筛选 🎨</h2>
<p>友链页面是最早动手做的功能页之一。设计目标很明确：<strong>简洁、好用、移动端友好</strong>。</p>
<h3>布局选择</h3>
<p>最终选了卡片网格布局，用 CSS Grid 的 <code>auto-fill</code> 自适应列数：</p>
<pre><code class="language-css">.grid-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
  gap: 1rem;
}
</code></pre>
<p>每张卡片最小 16rem，空间够了就自动多排一列，省去媒体查询的麻烦。</p>
<h3>交互细节</h3>
<ul>
<li><strong>搜索框</strong>：实时过滤站点名、描述、标签，输入即筛选</li>
<li><strong>标签筛选</strong>：点击标签快速过滤，激活态用反色按钮</li>
<li><strong>悬停效果</strong>：卡片上浮 + 头像从灰度变彩色</li>
</ul>
<pre><code class="language-css">.friend-card:hover {
  border-color: var(--grid-ink);
  transform: translateY(-4px);
  box-shadow: 0 8px 24px color-mix(in oklab, var(--grid-ink) 12%, transparent);
}
</code></pre>
<h3>头像兜底</h3>
<p>友链头像经常失效（毕竟别人的图床你管不了），所以做了 fallback：</p>
<pre><code class="language-svelte">&lt;img src={friend.imgurl} onerror={(e) =&gt; {
  (e.currentTarget as HTMLImageElement).style.display = 'none';
  (e.currentTarget.nextElementSibling as HTMLElement).style.display = 'flex';
}} /&gt;
&lt;span class="avatar-fallback"&gt;{initialOf(friend.title)}&lt;/span&gt;
</code></pre>
<p>图片挂了就显示首字符占位，不会出现裂图。这种细节虽然不起眼，但用户体感差别很大。</p>
<h3>响应式</h3>
<p>移动端自动切换为 2 列，超小屏（&lt;400px）切 1 列。筛选标签横向滚动，避免换行导致布局错乱。</p>
<hr />
<h2>四、站内自助友链申请表单 📝</h2>
<p>友链申请这个功能花的时间最多。一开始想的是跳转 GitHub Issue 模板提交，但这对不会用 GitHub 的访客太不友好了。最终方案是<strong>前台直接提交表单 → 后台审核队列 → 一键通过自动写入配置</strong>。</p>
<h3>后端架构</h3>
<p>后台 API 基于 Fastify，分了两组路由：</p>
<ul>
<li><strong>公开路由组</strong>：只有 <code>POST /friends/applications</code>（提交申请），无需登录</li>
<li><strong>受保护路由组</strong>：友链 CRUD、审核通过/拒绝等，需要 JWT 鉴权</li>
</ul>
<p>这里有个设计要点：公开接口必须从受保护路由组里<strong>单独拎出来</strong>注册，否则会被鉴权中间件拦掉。同时公开接口要加入 CSRF 豁免白名单：</p>
<pre><code class="language-typescript">const CSRF_EXEMPT = new Set&lt;string&gt;([
  `${API_PREFIX}/auth/login`,
  `${API_PREFIX}/auth/refresh`,
  `${API_PREFIX}/friends/applications`,  // 公开提交接口
]);
</code></pre>
<p>全局限流 200 次/分钟依然生效，防止有人拿脚本刷申请。</p>
<h3>前端表单组件</h3>
<p>用 Svelte 5 的 runes 语法写了表单组件，字段包括：</p>
<ul>
<li>站点名称（必填）</li>
<li>站点链接（必填，自动校验 URL）</li>
<li>头像链接（选填，留空自动抓取 favicon）</li>
<li>站点描述</li>
<li>分类标签</li>
<li>联系邮箱（选填，校验格式）</li>
</ul>
<p>几个细节优化：</p>
<ul>
<li><strong>URL 失焦自动抓取 favicon</strong>：用 Google 的 favicon 服务 <code>https://www.google.com/s2/favicons?sz=64&amp;domain=xxx</code></li>
<li><strong>三态反馈</strong>：<code>idle</code> / <code>success</code> / <code>error</code>，提交结果即时可见</li>
<li><strong>懒加载</strong>：用 <code>client:visible</code> 而不是 <code>client:load</code>，滚到才加载 JS</li>
</ul>
<h3>工作流程</h3>
<pre><code>访客填写表单 → POST 到后台公开接口
           → 写入 SQLite friend_applications 表（status='pending'）
           → 管理员登录后台「友链申请」页面
           → 点击「通过」→ 自动写入 friendsConfig.ts
</code></pre>
<p>审核通过后，友链会以默认权重 5、启用状态自动追加到 <code>friendsConfig.ts</code> 的数组里，下次构建即可见。这样访客提交完直接走人，站长后台一键通过，整个流程闭环。</p>
<hr />
<h2>五、细节调优：那些容易被忽略的小事 🔧</h2>
<h3>1. 关闭 Astro Dev Toolbar</h3>
<p>开发时页面右下角那个黑色的 Astro logo 按钮虽然功能丰富，但有时候确实碍眼。一行配置搞定：</p>
<pre><code class="language-javascript">// astro.config.mjs
export default defineConfig({
  devToolbar: {
    enabled: false,
  },
  // ...
});
</code></pre>
<h3>2. Footer 精简</h3>
<p>底部只保留「隐私政策」和「用户协议」两个入口，RSS 按钮去掉了。友链申请入口直接做成页面下方的表单，比塞在 Footer 里更合理。</p>
<h3>3. 友链申请指南弹窗</h3>
<p>做了一个 <code>&lt;dialog&gt;</code> 元素的申请指南弹窗，展示本站信息（可一键复制）、申请流程和注意事项。之前还在底部放了「自助申请友链」和「前往评论区」两个跳转按钮，后来有了站内表单，这两个跳转就多余了，直接删掉，UI 更干净。</p>
<hr />
<h2>六、后台一键构建 + 文章日期序列化踩坑 🔄</h2>
<p>这是开发过程中花时间最多的一个功能，也是踩坑踩得最狠的。背景很简单：<strong>Astro 是静态站点生成器（SSG），写完文章必须重新 <code>pnpm build</code> 才能在前台看到</strong>。本地开发还好，SSH 上服务器敲命令也能忍，但既然有后台管理系统了，为什么不直接在后台点一下就构建呢？</p>
<h3>需求拆解</h3>
<ul>
<li><strong>手动触发</strong>：Dashboard 上放个「重新构建」按钮，点一下就跑 <code>pnpm build</code></li>
<li><strong>自动触发</strong>：写/改/删文章后自动构建，省得忘</li>
<li><strong>实时日志</strong>：构建过程中把 stdout/stderr 实时展示出来，方便排查</li>
</ul>
<h3>构建管理器设计</h3>
<p>核心是一个单例的 <code>BuildManager</code>，挂在 Fastify 进程里：</p>
<pre><code class="language-typescript">// 同一时刻只允许一个构建任务，避免 dist 写入冲突
if (state.status === 'running') {
  pushLog('[skip] 已有构建任务在运行，本次触发已跳过');
  return;
}
</code></pre>
<p>用 <code>child_process.spawn</code> 跑 <code>pnpm run build</code>，stdout/stderr 都 pipe 进来收集日志，保留最近 500 行供前端轮询。构建完成时把结果（触发方式、状态、耗时、退出码）持久化到 SQLite 的 <code>build_history</code> 表，方便后续做统计。</p>
<p><strong>去抖机制</strong>很关键——后台编辑文章时经常会连续保存（写一段 Ctrl+S 一下），如果每次保存都触发构建，磁盘和 CPU 都扛不住。所以自动触发加了 3 秒去抖：</p>
<pre><code class="language-typescript">export function triggerBuildDebounced(delayMs = 3000): void {
  if (state.status === 'running') return;  // 运行中不叠加
  if (pendingTimer) clearTimeout(pendingTimer);
  pendingTimer = setTimeout(() =&gt; {
    pendingTimer = null;
    runBuild('auto');
  }, delayMs);
}
</code></pre>
<h3>Dashboard 集成</h3>
<p>前端在 Dashboard 底部加了个「前端构建」卡片，包含：</p>
<ul>
<li>状态标签（空闲 / 构建中 / 成功 / 失败）</li>
<li>「重新构建」按钮（构建中自动 loading + 禁用）</li>
<li>上次构建用时和完成时间</li>
<li>构建日志框（每 5 秒轮询一次，实时刷新）</li>
</ul>
<p>这里踩了个小坑：一开始用 <code>pnpm --dir &lt;path&gt; run build</code> 传项目路径，结果 Windows 下路径含空格（<code>C:\Users\William Chih\...</code>）被截断成 <code>C:\Users\William</code>，构建直接报 <code>ENOENT</code>。解决方案是<strong>不用 <code>--dir</code> 参数，直接用 <code>spawn</code> 的 <code>cwd</code> 选项</strong>：</p>
<pre><code class="language-typescript">const child = spawn('pnpm', ['run', 'build'], {
  cwd: config.blogRoot,  // 工作目录交给 cwd，不用命令行参数
  shell: process.platform === 'win32',  // Windows 需要 shell 解析 pnpm.cmd
});
</code></pre>
<h3>Date 序列化：最阴间的坑</h3>
<p>功能写完测试时发现一个诡异问题：<strong>后台保存文章后，自动构建成功了，但前台首页死活不显示新文章</strong>。去 dev server 里一看，整个 Content Collections 直接崩了，所有文章（旧的新的）全不见了。</p>
<p>排查发现是新文章的 frontmatter 里 <code>published</code> 字段被序列化成了带引号的字符串：</p>
<pre><code class="language-yaml"># 实际生成的（错误）
published: '2026-08-12T17:30:02'

# Astro schema 期望的（正确）
published: 2026-08-12T17:30:02
</code></pre>
<p>Astro 7 的 Content Collections schema 把 <code>published</code> 定义为 <code>z.date()</code>，YAML 里加引号会被解析成 <code>string</code> 而非 <code>date</code>，schema 校验失败 → 整个 Collections 初始化崩掉 → 所有文章不显示。</p>
<p>根因是后台保存文章时，<code>published</code> 字段存的是字符串（<code>new Date().toISOString()</code>），<code>gray-matter</code> 库在 <code>stringify</code> 时把它当成普通字符串序列化，YAML 自动加了引号。</p>
<p>修复方案是<strong>把字符串转成 <code>Date</code> 对象再交给 gray-matter</strong>，YAML 序列化 <code>Date</code> 时会输出原生日期格式（不带引号）：</p>
<pre><code class="language-typescript">// 修改前：存的是字符串，会被加引号
published: data.published ?? new Date().toISOString()

// 修改后：存 Date 对象，YAML 原生日期格式
published: new Date(data.published ?? Date.now())
</code></pre>
<p>创建和编辑两个接口都要改，凡是涉及 <code>published</code> 和 <code>updated</code> 的赋值都包一层 <code>new Date()</code>。这个坑真的很隐蔽——本地 dev 模式下文章能正常显示（dev server 对类型校验相对宽松），但 <code>astro build</code> 时会严格执行 schema，构建失败。如果没开自动构建，可能要等到部署时才发现。</p>
<blockquote>
<p>💡 <strong>教训</strong>：Content Collections 的 schema 类型不是摆设。YAML 的类型系统和 TypeScript 的类型系统是两套，<code>gray-matter.stringify</code> 不会自动把字符串转成 YAML 的 date 类型，必须传入 <code>Date</code> 对象。<strong>凡是 schema 里定义为 <code>z.date()</code> 的字段，写入时一律用 <code>new Date()</code> 包一层</strong>。</p>
</blockquote>
<h3>slug 自动生成</h3>
<p>顺手解决了一个 UX 问题：之前后台新建文章时 <code>slug</code> 字段是必填的，对不熟悉技术的人来说很懵——"slug 是啥？"。改成留空自动生成：</p>
<pre><code class="language-typescript">let slug = (data.slug ?? '').trim();
if (!slug) {
  slug = `post-${Date.now().toString(36)}`;  // base36 时间戳，短且唯一
  let suffix = 1;
  while (existsSync(slugToPath(slug))) {
    slug = `post-${Date.now().toString(36)}-${suffix++}`;
  }
}
</code></pre>
<p>生成的 slug 形如 <code>post-msqd7cgi</code>，够短、够唯一、够语义化。当然用户想自定义也完全可以，字段只是从必填改成了可选。</p>
<hr />
<h2>七、错误总结 💡</h2>
<table>
<thead>
<tr>
<th>问题</th>
<th>根因</th>
<th>解决方案</th>
</tr>
</thead>
<tbody><tr>
<td><code>better-sqlite3</code> 编译失败</td>
<td>缺 MSVC 工具集 / GLIBC 版本低</td>
<td>双驱动降级到 <code>node:sqlite</code></td>
</tr>
<tr>
<td>Fastify 插件超时</td>
<td>tsx 转译慢 + 默认 10s 超时</td>
<td><code>pluginTimeout: 0</code></td>
</tr>
<tr>
<td>进程静默退出</td>
<td>事件循环空转 / 未捕获异常</td>
<td>全局错误处理 + keepAlive</td>
</tr>
<tr>
<td>Astro preview 外部访问失败</td>
<td>默认绑 localhost</td>
<td><code>--host 0.0.0.0</code></td>
</tr>
<tr>
<td><code>src/api.ts</code> 与 <code>src/api/</code> 命名冲突</td>
<td>Vite/Rollup 模块解析</td>
<td>重命名为 <code>src/http.ts</code></td>
</tr>
<tr>
<td>图片加载失败出现裂图</td>
<td>友链头像失效</td>
<td><code>onerror</code> 回退到首字符占位</td>
</tr>
<tr>
<td>构建命令路径被截断</td>
<td><code>pnpm --dir</code> 参数含空格</td>
<td>改用 <code>spawn</code> 的 <code>cwd</code> 选项</td>
</tr>
<tr>
<td>新文章导致全站文章消失</td>
<td><code>published</code> 字段被序列化成带引号字符串</td>
<td>用 <code>new Date()</code> 包一层，输出 YAML 原生日期</td>
</tr>
</tbody></table>
<blockquote>
<p>💡 <strong>关于 <code>src/api.ts</code> 那个坑</strong>：Astro 项目里文件名和目录名同名会导致 Vite/Rollup 模块解析出错，报错信息还特别误导。养成习惯：<strong>文件名和同级目录名不要重复</strong>。</p>
</blockquote>
<blockquote>
<p>💡 <strong>关于 Date 序列化那个坑</strong>：这个 bug 在本地 dev 模式下可能完全不暴露，只有 <code>astro build</code> 时才会触发。建议开发时<strong>定期跑一次完整构建</strong>，别等部署时才发现问题。</p>
</blockquote>
<hr />
<h2>八、技术选型回顾 📐</h2>
<p>回过头看整个技术选型，大概是这样的决策链：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>选择</th>
<th>理由</th>
</tr>
</thead>
<tbody><tr>
<td>前台框架</td>
<td>Astro</td>
<td>零 JS by default，内容站点性能拉满</td>
</tr>
<tr>
<td>交互组件</td>
<td>Svelte 5</td>
<td>编译产物小，runes 语法写起来舒服</td>
</tr>
<tr>
<td>样式</td>
<td>Tailwind CSS 4</td>
<td>原子化 + CSS 变量主题切换两不误</td>
</tr>
<tr>
<td>后台框架</td>
<td>Fastify</td>
<td>比 Express 快，插件系统清晰</td>
</tr>
<tr>
<td>数据库</td>
<td>SQLite</td>
<td>单文件部署，博客场景够用</td>
</tr>
<tr>
<td>包管理</td>
<td>npm</td>
<td>兼容性好，服务器构建不出幺蛾子</td>
</tr>
</tbody></table>
<hr />
<h2>九、CSRF 校验移除 + 用户名修改功能 🔓</h2>
<h3>CSRF 校验：从有到无</h3>
<p>后台一开始做了完整的 CSRF 防护——登录时签发 csrfToken 写入 Cookie，前端 Axios 拦截器自动读取并附加 <code>X-CSRF-Token</code> 请求头，后端全局 <code>preHandler</code> 校验所有非 GET 请求。设计上很标准，但实际用起来：</p>
<p><strong>后台操作老显示「CSRF 校验失败」</strong>——Cookie 的 <code>Secure</code> 标记在 HTTP 环境下导致浏览器拒绝存储 csrfToken，前端拿不到 token，所有写操作全被 403。虽然之前修过一次（按 <code>request.protocol</code> 判断是否加 Secure），但反代环境下协议判断又有边界 case。</p>
<p>纠结了一圈，最终决定<strong>彻底移除 CSRF 校验</strong>——后台管理系统是内网性质（复杂路径 + JWT 鉴权 + 密码二次确认），CSRF 带来的收益不如它造成的问题多：</p>
<pre><code class="language-typescript">// 之前：全局 preHandler 里校验 CSRF
app.addHook('preHandler', async (request, reply) =&gt; {
  if (!isCsrfExempt(request) &amp;&amp; !await verifyCsrf(request)) {
    return fail(reply, 'CSRF 校验失败', 403);
  }
});
// 现在：整段删掉
</code></pre>
<p>移除涉及 7 个文件——后端删 <code>requireCsrf</code> 函数、删 <code>CSRF_EXEMPT</code> 白名单、删全局钩子；前端删 Axios 拦截器、删 <code>csrfToken</code> 状态、删类型定义。删干净比加进去还费劲。</p>
<h3>修改用户名功能</h3>
<p>后台本来只有改密码，没有改用户名。用户名想改只能 SSH 上服务器敲 SQL。既然「账号与访问」页面已经有了密码修改和端口配置，加个用户名修改 Tab 顺理成章：</p>
<pre><code class="language-typescript">// POST /system/username
// 参数校验：3-20 位，字母/数字/下划线
const changeUsernameSchema = z.object({
  password: z.string().min(1).max(128),
  newUsername: z.string().min(3).max(20)
    .regex(/^[a-zA-Z0-9_]+$/),
});
</code></pre>
<p>安全措施和改密码一致：密码二次确认 → 查重（不能和已有用户名重复）→ 改完吊销所有令牌 + 清 cookie（JWT 里含旧用户名，必须重新登录）→ 操作日志记录。</p>
<p>前端在「账号与访问」页面加了第二个 Tab，显示当前用户名、新用户名输入框、密码确认框。提交后 1.5 秒自动跳转登录页，用新用户名 + 旧密码登录。</p>
<blockquote>
<p>💡 <strong>教训</strong>：CSRF 防护在「内网管理系统 + HTTPS 反代」场景下，收益不如成本高。如果你的后台已经有多层鉴权（JWT + 复杂路径 + 密码确认），可以考虑移除 CSRF，省掉一堆 Cookie/Secure/SameSite 的边界问题。</p>
</blockquote>
<hr />
<h2>十、构建系统全面优化：2 核 2G 服务器防卡死 ⚡</h2>
<p>这是投入精力最多的一轮优化。服务器配置是 <strong>2 核 2G</strong>，Astro 全量构建吃内存很凶，频繁出现「构建卡死」——后台点「重新构建」后进度条转一辈子，自动构建（保存文章后触发）也经常无声中断。</p>
<h3>卡死的三大根因</h3>
<table>
<thead>
<tr>
<th>根因</th>
<th>表现</th>
<th>发生条件</th>
</tr>
</thead>
<tbody><tr>
<td><strong>OOM 杀进程</strong></td>
<td>子进程被内核静默杀掉，无 stderr 输出</td>
<td>2G 内存无 swap，构建峰值超 1.5G</td>
</tr>
<tr>
<td><strong>Swap 抖动</strong></td>
<td>进程活着但极慢，几乎无输出</td>
<td>有 swap 但太少，疯狂换页</td>
</tr>
<tr>
<td><strong>超时太晚</strong></td>
<td>卡了 20 分钟才被超时发现</td>
<td><code>BUILD_TIMEOUT_MS = 20min</code> 太长</td>
</tr>
</tbody></table>
<h3>修复 1：Swap 自动创建（根本手段）</h3>
<p><code>start.sh</code> 新增 <code>ensure_swap()</code> 函数，构建前自动检测：</p>
<pre><code class="language-bash">ensure_swap() {
  # 读取 SwapTotal 和 MemTotal
  # 总内存 &lt;= 2G 且无 swap → 自动创建 2G swap 文件
  fallocate -l 2G /swapfile   # 秒建（回退 dd）
  chmod 600 /swapfile
  mkswap /swapfile &amp;&amp; swapon /swapfile
  # 写入 /etc/fstab 实现重启后自动挂载
  echo "/swapfile none swap defaults 0 0" &gt;&gt; /etc/fstab
}
</code></pre>
<p>有了 2G swap，即使物理内存只有 2G，构建峰值 1.5G 也不会被 OOM 杀掉——最差也就是慢一点（走 swap），但能跑完。</p>
<h3>修复 2：停滞检测（120 秒无输出 = 卡死）</h3>
<p>之前只有 20 分钟总超时，太被动。新增<strong>停滞检测</strong>——每 30 秒检查一次 <code>lastOutputAt</code>，超过 120 秒没有任何 stdout/stderr 输出就判定卡死，立即强杀：</p>
<pre><code class="language-typescript">let lastOutputAt: number | null = null;
const STALL_TIMEOUT_MS = 120 * 1000;

// 子进程每次输出都更新 lastOutputAt
const handleLine = (data: Buffer) =&gt; {
  lastOutputAt = Date.now();
  // ...
};

// 每 30 秒轮询
stallTimer = setInterval(() =&gt; {
  if (lastOutputAt &amp;&amp; Date.now() - lastOutputAt &gt; STALL_TIMEOUT_MS) {
    pushLog(`[stall] 构建已 ${stallSec} 秒无输出，判定为卡死`);
    killBuildTree();  // 强杀整个进程树
  }
}, 30_000);
</code></pre>
<p>这个机制能捕获两种之前无法检测的场景：</p>
<ol>
<li><strong>OOM 杀进程后子进程静默退出</strong>——没有 close 事件触发（有时），但肯定没有输出了</li>
<li><strong>Swap 抖动假死</strong>——进程活着但慢到几乎不输出，120 秒够判断了</li>
</ol>
<h3>修复 3：堆内存上限适配低配服务器</h3>
<p>之前堆上限是 <code>[512, 2048] MB</code>，对 2G 服务器太激进。改为按总内存分级：</p>
<table>
<thead>
<tr>
<th>总内存</th>
<th>堆上限范围</th>
<th>比例</th>
<th>给系统留</th>
</tr>
</thead>
<tbody><tr>
<td>≤ 2G</td>
<td>[384, 768] MB</td>
<td>50%</td>
<td>~1.2G</td>
</tr>
<tr>
<td>&gt; 2G</td>
<td>[512, 1024] MB</td>
<td>55%</td>
<td>充裕</td>
</tr>
</tbody></table>
<pre><code class="language-typescript">function computeHeapCapMb(): number {
  const totalMb = Math.floor(os.totalmem() / 1024 / 1024);
  const ratio = totalMb &lt;= 2048 ? 0.50 : 0.55;
  const maxCap = totalMb &lt;= 2048 ? 768 : 1024;
  // ...
}
</code></pre>
<h3>修复 4：构建工具从 pnpm 统一为 npm</h3>
<p>构建命令原来走 <code>pnpm run build</code>，但服务器上 pnpm 的依赖完整性检查经常出问题（optional native binding 安装失败、build scripts 被 ignore）。统一改为 <code>npm run build</code>：</p>
<pre><code class="language-typescript">// builder.ts
function getRunner() {
  return { cmd: 'npm', args: ['run', 'build'] };
}
</code></pre>
<p>同时 <code>package.json</code> 的 build 脚本改为直接调 node，绕过 <code>.bin/</code> 软链（服务器 npm install 不完整时软链可能缺失）：</p>
<pre><code class="language-json">{
  "build": "node scripts/generate-icons.js &amp;&amp; node node_modules/astro/bin/astro.mjs build &amp;&amp; node node_modules/pagefind/lib/runner/bin.cjs --site dist"
}
</code></pre>
<h3>修复 5：总超时降低</h3>
<table>
<thead>
<tr>
<th>参数</th>
<th>旧版</th>
<th>新版</th>
</tr>
</thead>
<tbody><tr>
<td>总超时</td>
<td>20 分钟</td>
<td>15 分钟</td>
</tr>
<tr>
<td>停滞超时</td>
<td>无</td>
<td>120 秒（新增）</td>
</tr>
</tbody></table>
<p>有了停滞检测兜底，总超时可以短一些。15 分钟对于个人博客的全量构建已经非常充裕了。</p>
<h3>效果</h3>
<p>优化前：构建 10 次卡死 7 次，只能 SSH 手动重启服务。
优化后：swap 自动创建后不再 OOM，偶发卡死 2 分钟内被停滞检测捕获并强杀，状态从 <code>running</code> 落为 <code>failed</code>，后续构建自动恢复，不再永久卡死。</p>
<blockquote>
<p>💡 <strong>教训</strong>：2G 服务器跑 Astro 构建必须有 swap，这不是可选项是必选项。没有 swap 时 OOM-killer 会杀进程且不一定触发 close 事件，导致构建状态永久卡在 <code>running</code>。停滞检测（无输出超时）比单纯的总超时更有效——它能区分「正常构建中偶尔沉默」和「已经死了只是进程没退出」。</p>
</blockquote>
<hr />
<h2>十一、域名绑定与静态托管 🌐</h2>
<h3>从 preview 到 nginx 静态托管</h3>
<p>之前前端跑 <code>astro preview</code>（Node 进程），通过 <code>IP:4321</code> 访问。绑定域名 <code>qiguai.net</code> 后遇到一堆问题：</p>
<ol>
<li><strong>Vite <code>allowedHosts</code> 拦截</strong>：<code>Blocked request. This host ("qiguai.net") is not allowed.</code>——Vite 默认只允许 localhost</li>
<li><strong>端口暴露</strong>：访问 <code>IP:4321</code> 不够优雅，也不安全</li>
<li><strong>preview 进程占内存</strong>：2G 服务器多跑一个 Node 进程很奢侈</li>
</ol>
<p>最终方案是<strong>彻底去掉 preview，改用 nginx 静态托管</strong>：</p>
<pre><code>宝塔建静态站点 qiguai.net → 网站目录指向 /www/wwwroot/my-blog-master/dist
</code></pre>
<p>Astro 是 <code>output: "static"</code>，<code>dist/</code> 目录就是纯 HTML/CSS/JS，nginx 直接发静态文件，不需要 Node 中间层。<code>start.sh</code> 的 <code>prod</code> 模式从启动 preview 改为只启动后台 + 提示「nginx → dist/ 静态托管」。</p>
<p>好处：省一个 Node 进程、更快（nginx 发静态文件比 preview 快）、没有 <code>allowedHosts</code> / 端口 / Host 头问题、内存占用少。更新文章 → <code>npm run build</code> → nginx 自动 serve 新 dist，前端连重启都不用。</p>
<h3>后台反代</h3>
<p>后台（Fastify 8787 端口）通过子域名 <code>xxxx.qiguai.net</code> 反代：</p>
<pre><code>宝塔建站点 xxxx.qiguai.net → 反向代理 → http://127.0.0.1:8787
</code></pre>
<p><strong>踩坑</strong>：反代目标一开始写的 <code>http://xxxxx:8787</code>（公网 IP），阿里云服务器访问自己的公网 IP 会被安全组拦。改为 <code>http://127.0.0.1:8787</code> 就好了——请求不走出服务器，不经安全组，速度快。</p>
<h3>端口修改功能的角色变化</h3>
<p>绑定域名后，「改端口」功能基本没用了：</p>
<table>
<thead>
<tr>
<th>功能</th>
<th>之前</th>
<th>nginx 反代后</th>
</tr>
</thead>
<tbody><tr>
<td>前端端口</td>
<td>preview 监听端口</td>
<td><strong>无用</strong>——nginx 直托管 dist/，不涉及端口</td>
</tr>
<tr>
<td>后台端口</td>
<td>后端换端口重启</td>
<td><strong>改了反而坏事</strong>——nginx 指向旧端口，反代断链</td>
</tr>
<tr>
<td>后台路径</td>
<td>SPA 重建 + 重启</td>
<td><strong>仍有用</strong>——路径是 URL 路径，nginx 透传</td>
</tr>
<tr>
<td>改密码</td>
<td>—</td>
<td><strong>仍有用</strong></td>
</tr>
</tbody></table>
<p>端口对外已经隐身（走域名），没必要改了。路径和密码继续用。</p>
<h3>Vite allowedHosts</h3>
<p>虽然改成了 nginx 静态托管（不再跑 preview），但 <code>astro.config.mjs</code> 里还是加了 <code>allowedHosts</code>，以备本地 dev 时用域名访问：</p>
<pre><code class="language-javascript">vite: {
  preview: {
    allowedHosts: ['qiguai.net', 'www.qiguai.net'],
  },
},
</code></pre>
<blockquote>
<p>💡 <strong>教训</strong>：静态站点（<code>output: "static"</code>）最自然的部署方式就是 nginx 直托管，不需要 Node 进程。<code>astro preview</code> 适合本地预览，不适合生产——多一个 Node 进程、有 Host 检查问题、占内存。反代目标永远用 <code>127.0.0.1</code>，不要用公网 IP——云服务器访问自己的公网 IP 会被安全组拦截。</p>
</blockquote>
<hr />
<h2>写在最后 🎯</h2>
<ol>
<li><strong>架构分层要清晰</strong>，前台静态、后台动态、数据库单文件，各司其职</li>
<li><strong>能自动化的流程坚决不手动</strong>，从部署到友链审核</li>
</ol>
<p>Astro 的 Islands Architecture 对博客这种内容站点真的特别合适——默认零 JS，只在需要交互的组件注水，Lighthouse 跑分看着就舒服。再加上 Svelte 5 的 runes 语法、Tailwind 4 的 CSS 变量主题，开发体验整体很丝滑。</p>
<p>谢谢大家</p>
]]></content:encoded></item></channel></rss>