<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" 
  xmlns:content="http://purl.org/rss/1.0/modules/content/" 
  xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>MoonGate</title>
    <link>https://moongate.top</link>
    <description>Where Moon Meets Code</description>
    <language>zh-CN</language>
    <lastBuildDate>Tue, 15 Sep 2026 12:20:50 GMT</lastBuildDate>

    <item>
      <title><![CDATA[用 Go 重构 Markdown 加载：从 Nuxt Content 到独立数据 API]]></title>
      <link>https://moongate.top/docs/go-markdown-loader</link>
      <guid isPermaLink="true">https://moongate.top/docs/go-markdown-loader</guid>
      <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-背景">一、背景</h2>

<p>博客原本用 Nuxt Content 管理 39 篇技术文章，整体体验不错，但有两个痛点：</p>

<ol>
<li><strong>内容模型与渲染行为分散</strong>：每新增一种内容类型，都要在 <code>content.config.ts</code> 加 collection 与 zod schema。想调整代码高亮、目录深度或主题，又得改 <code>nuxt.config.ts</code>。配置本身就在累积复杂度。</li>
<li><strong>内容与构建耦合</strong>：内容更新需要重新构建前端，GitHub Actions 每次跑 3-4 分钟——改错别字也要等这么久，累积起来就不少了。</li>
</ol>

<p>两个痛点叠加，让我决定把&rdquo;内容加载&rdquo;从 Nuxt 中拆出来：需求足够小、边界清晰，适合先用 Go 实现一个独立的 Markdown 数据源，验证自建方案的可行性。</p>

<h2 id="二-整体设计">二、整体设计</h2>

<p>这次拆解有明确的目标：<strong>内容与代码分离</strong>（改内容不再触发前端重建）、<strong>技术透明</strong>（每行代码在自己掌控中）、<strong>轻量依赖</strong>（按需引入，不背全家桶）。整体设计如下：</p>

<pre><code class="language-text">┌──────────────────────────────────────────────┐
│ content/  Markdown 内容                       │
│  docs/   技术文章 + *.en.md 英文译文          │
│  about/  关于页面                             │
└──────────────────────┬───────────────────────┘
                       ▼
┌──────────────────────────────────────────────┐
│ Go 程序启动时一次性加载                        │
│  递归遍历 .md → 解析 frontmatter + Markdown→  │
│  HTML → 按 slug 存入内存 map（中文 + 译文）    │
└──────────────────────┬───────────────────────┘
                       ▼
┌──────────────────────────────────────────────┐
│ Gin API                                      │
│  GET /api/docs[:slug]   列表/单篇/系列分组/   │
│                         语言回退              │
│  GET /api/about[:slug]  关于页                │
└──────────────────────────────────────────────┘
</code></pre>

<p>技术选型：</p>

<table>
<thead>
<tr>
<th>组件</th>
<th>选择</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Web 框架</td>
<td>Gin</td>
<td>轻量、性能好、路由直观</td>
</tr>

<tr>
<td>YAML 解析</td>
<td><code>github.com/goccy/go-yaml</code></td>
<td>解析快，日期格式友好</td>
</tr>

<tr>
<td>Markdown 渲染</td>
<td><code>gomarkdown</code></td>
<td>纯 Go，无 CGO 依赖</td>
</tr>
</tbody>
</table>

<h2 id="三-项目结构-概览">三、项目结构（概览）</h2>

<p>代码按职责分三个包（完整目录见仓库）：</p>

<ul>
<li><code>internal/domain</code>：Doc / About 领域模型，以及&rdquo;能设置 slug 与正文&rdquo;的 <code>ContentSetter</code> 接口；</li>
<li><code>internal/loader</code>：递归扫描 content 目录、frontmatter + Markdown 解析、双语配对、内存存储；</li>
<li><code>internal/api</code>：Gin Handler，负责列表、单篇与语言回退。</li>
</ul>

<p>入口直接放在根目录 <code>main.go</code>——项目不大，不需要 <code>cmd/</code> 分层。</p>

<h2 id="四-数据模型">四、数据模型</h2>

<h3 id="4-1-doc-结构体">4.1 Doc 结构体</h3>

<pre><code class="language-go">// internal/domain/doc.go（节选）
type Doc struct {
	Title       string    `yaml:&quot;title&quot; json:&quot;title&quot;`
	Description string    `yaml:&quot;description&quot; json:&quot;description&quot;`
	Date        time.Time `yaml:&quot;date&quot; json:&quot;date&quot;`
	Slug        string    `yaml:&quot;slug,omitempty&quot; json:&quot;slug&quot;`   // 由文件名生成
	Series      *string   `yaml:&quot;series&quot; json:&quot;series&quot;`         // nil = 不属于任何系列
	Order       *int      `yaml:&quot;order,omitempty&quot; json:&quot;order&quot;` // nil = 未声明阅读顺序
	Tags        []string  `yaml:&quot;tags&quot; json:&quot;tags&quot;`
	Content     string    `json:&quot;content&quot;`        // 正文 HTML（Markdown 转换而来）
	Lang        string    `json:&quot;lang&quot;`           // 实际返回语言：zh | en
	IsFallback  bool      `json:&quot;isFallback&quot;`     // 是否发生了语言回退
	HasTranslation bool   `json:&quot;hasTranslation&quot;` // 该 slug 是否存在英文译文
}
</code></pre>

<p><code>Series</code>、<code>Order</code> 用指针而不是值类型：<code>nil</code> 表示&rdquo;没有&rdquo;，JSON 序列化时自动省略，从而区分&rdquo;空值&rdquo;和&rdquo;不存在&rdquo;。<code>Slug</code> 不写在 frontmatter 里，由 loader 从文件名生成。末尾三个 <code>Lang/IsFallback/HasTranslation</code> 字段不带 <code>yaml</code> tag，由 API 层按请求语言回填，因此不参与 YAML 解析。</p>

<p>列表接口不返回整篇正文，而是返回去掉 <code>Content</code> 的摘要 <code>DocSummary</code>，需要系列分组时再用 <code>SeriesGroup</code> 包装；<code>About</code> 字段更精简、结构与 Doc 同思路。这些类型连同它们的 Setter 方法都定义在 <code>internal/domain/</code> 下（<code>doc.go</code> / <code>about.go</code>），正文不再重复贴。</p>

<h3 id="4-2-contentsetter-接口">4.2 ContentSetter 接口</h3>

<p><code>Doc</code> 与 <code>About</code> 各自实现 <code>SetSlug</code> / <code>SetContent</code> 两个方法（接口定义见仓库 <code>domain/content.go</code>）。这样解析函数不需要为每种类型写一份：它只关心&rdquo;类型 T 有没有这两个 setter&rdquo;。泛型 <code>[T any]</code> 负责类型安全，接口负责把&rdquo;能设置 slug 与正文&rdquo;这个能力抽象出来——两者配合，<code>ParseMarkdown</code> 一套逻辑通吃两种类型。</p>

<h2 id="五-核心解析逻辑">五、核心解析逻辑</h2>

<h3 id="5-1-文件格式">5.1 文件格式</h3>

<pre><code class="language-yaml">---
title: Go 后端开发实践
description: 从 Markdown 到内存的完整方案
date: 2026-07-10
series: backend
tags:
  - Go
  - Markdown
---
# 正文内容
</code></pre>

<p>文件名即 slug。<code>docs/go-backend.md</code> → <code>slug=go-backend</code>；<code>docs/go-backend.en.md</code> 则去掉 <code>.en</code> 后缀，得到同一个 slug 与中文配对。<code>content/docs/</code> 下可以任意嵌套子目录，loader 会递归扫描。</p>

<h3 id="5-2-解析函数">5.2 解析函数</h3>

<pre><code class="language-go">// internal/loader/parse.go（节选，完整实现见仓库）
func ParseMarkdown[T any](filePath string) (T, error) {
	var result T

	// 1. 读取文件
	data, err := os.ReadFile(filePath)
	if err != nil {
		return result, fmt.Errorf(&quot;读取文件失败: %w&quot;, err)
	}

	// 2. 按 &quot;---&quot; 分割 frontmatter 与正文
	parts := strings.SplitN(string(data), &quot;---&quot;, 3)
	if len(parts) != 3 {
		return result, fmt.Errorf(&quot;无效的 frontmatter 格式: %s&quot;, filePath)
	}

	// 3. 解析 YAML 到目标结构体
	err = yaml.Unmarshal([]byte(parts[1]), &amp;result)
	if err != nil {
		return result, fmt.Errorf(&quot;解析 frontmatter 失败: %w&quot;, err)
	}

	// 4. 文件名生成 slug：*.en.md 去掉 .en 后缀，与中文共享同一 slug
	baseName := filepath.Base(filePath)
	slug := strings.TrimSuffix(baseName, filepath.Ext(baseName))
	slug = strings.TrimSuffix(slug, &quot;.en&quot;)

	// 5. 泛型 + 接口：T 只要实现 ContentSetter，就统一回填 Slug 与 HTML 正文
	htmlContent := mdToHTML(parts[2])
	if setter, ok := any(&amp;result).(domain.ContentSetter); ok {
		setter.SetSlug(slug)
		setter.SetContent(htmlContent)
	}

	return result, nil
}
</code></pre>

<p>这里有两个设计点。其一，正文 Markdown 在<strong>加载期</strong>一次性转成 HTML 并缓存，请求期零解析——所以列表接口默认不返回 <code>Content</code>，只有显式 <code>?content=true</code> 才带正文。其二，frontmatter 切分用的是最朴素的 <code>SplitN</code>，其边界（正文里出现 <code>---</code>）见文末&rdquo;已知约束&rdquo;。</p>

<h3 id="5-3-markdown-html-与-details-预处理">5.3 Markdown → HTML 与 details 预处理</h3>

<p>渲染用 gomarkdown（CommonMark 实现、纯 Go 无 CGO）。解析与渲染各两行配置——<code>parser.CommonExtensions | AutoHeadingIDs</code> 与 <code>html.CommonFlags | HrefTargetBlank</code>，见仓库 <code>loader/html.go</code>。这里有一个真实踩过的坑：gomarkdown 把 <code>&lt;details&gt;…&lt;/details&gt;</code> 整段当作&rdquo;原始 HTML&rdquo;透传，不解析其中的 Markdown。details 内的代码围栏因此退化成纯文本。对策是在渲染前用 <code>expandDetailsCodeFences</code> 改写为真实的 <code>&lt;pre&gt;&lt;code&gt;</code>。改写时会做 HTML 转义，与前端 Shiki 的反义清单严格对应；实现见 <code>loader/details.go</code> 及其单元测试。</p>

<h2 id="六-加载与内存存储">六、加载与内存存储</h2>

<p>双语内容靠&rdquo;按 slug 索引的三张 map&rdquo;组织：中文/默认、英文译文、关于页各一张（<code>Store</code> 定义见仓库 <code>load.go</code>）。入口 <code>LoadAll</code>：</p>

<pre><code class="language-go">// internal/loader/load.go（节选）
func LoadAll(contentDir string) (*Store, error) {
	store := &amp;Store{
		DocsBySlug:  make(map[string]*domain.Doc),
		DocsEn:      make(map[string]*domain.Doc),
		AboutBySlug: make(map[string]*domain.About),
	}

	if err := loadDocs(filepath.Join(contentDir, &quot;docs&quot;), store); err != nil {
		return nil, fmt.Errorf(&quot;加载 docs 失败: %w&quot;, err)
	}
	if err := loadAbout(filepath.Join(contentDir, &quot;about&quot;), store); err != nil {
		return nil, fmt.Errorf(&quot;加载 about 失败: %w&quot;, err)
	}

	// 健壮性提示：英文译文没有对应的中文文章
	for slug := range store.DocsEn {
		if _, ok := store.DocsBySlug[slug]; !ok {
			fmt.Printf(&quot;⚠️ 英文译文 %s.en.md 没有对应的中文文章\n&quot;, slug)
		}
	}
	return store, nil
}
</code></pre>

<p>docs 的核心加载逻辑（<code>loadDocs</code> 节选）：</p>

<pre><code class="language-go">// loadDocs 递归加载 content/docs/ 下所有技术文章（支持子目录与 .en.md 译文）
func loadDocs(dir string, store *Store) error {
	return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || filepath.Ext(path) != &quot;.md&quot; { // 只处理 .md 文件
			return nil
		}

		doc, err := ParseMarkdown[domain.Doc](path)
		if err != nil {
			fmt.Printf(&quot;⚠️ 跳过 %s: %v\n&quot;, path, err) // 单文件失败只警告，服务照常启动
			return nil
		}
		if doc.Series != nil &amp;&amp; *doc.Series == &quot;&quot; { // YAML 空串 → nil，语义与&quot;未声明&quot;一致
			doc.Series = nil
		}

		// 双语分流：.en.md 进译文 map，其余进默认 map
		if strings.HasSuffix(path, &quot;.en.md&quot;) {
			store.DocsEn[doc.Slug] = &amp;doc
		} else {
			store.DocsBySlug[doc.Slug] = &amp;doc
		}
		return nil
	})
}
</code></pre>

<p>几点设计说明：</p>

<ul>
<li><strong>键用 slug 而不是自增 id</strong>：slug 直接对应文件名与 URL（<code>/api/docs/:slug</code>）。中文与英文译文共享同一 slug，靠 <code>.en.md</code> 后缀分流到 <code>DocsBySlug</code> 与 <code>DocsEn</code> 两张 map。</li>
<li><strong><code>docs/</code> 递归、<code>about/</code> 扁平</strong>：博客按系列分了子目录（如 <code>docs/narrative-engine/</code>），<code>loadDocs</code> 因此用 <code>filepath.WalkDir</code> 递归。<code>about/</code> 既无子目录也无译文，<code>loadAbout</code> 用 <code>filepath.Glob</code> 一层即可（见仓库 <code>load.go</code>）。</li>
</ul>

<h2 id="七-提供-http-api">七、提供 HTTP API</h2>

<p>API 层在 <code>internal/api/</code> 下。核心是 <code>DocsHandler</code>，持有两张按 slug 索引的 map，提供四组路由：</p>

<ul>
<li><code>GET /api/docs</code>：摘要列表，支持 <code>page</code>/<code>limit</code>/<code>search</code>/<code>tag</code>/<code>group=series</code>/<code>lang</code>，<code>?content=true</code> 才返回含正文的完整文档；</li>
<li><code>GET /api/docs/:slug</code>：单篇，<code>?lang=en</code> 优先译文、无译文自动回退；</li>
<li><code>GET /api/about</code> / <code>GET /api/about/:slug</code>：关于页列表与单篇；</li>
<li><code>GET /health</code>：健康检查。</li>
</ul>

<p>Handler 最关键的一段是语言回退解析（<code>resolveDoc</code>，节选）：</p>

<pre><code class="language-go">// internal/api/docs.go（节选，完整实现见仓库）
// resolveDoc 按请求语言解析文档：lang=en 时优先英文译文、无则回退中文；
// 其余语言优先中文、缺失时兜底英文（防 404）
func (h *DocsHandler) resolveDoc(slug, lang string) (doc *domain.Doc, resolvedLang string, isFallback bool) {
	if lang == &quot;en&quot; {
		if en, ok := h.StoreEn[slug]; ok {
			return en, &quot;en&quot;, false
		}
		if zh, ok := h.StoreBySlug[slug]; ok {
			return zh, &quot;zh&quot;, true
		}
		return nil, &quot;&quot;, false
	}
	if zh, ok := h.StoreBySlug[slug]; ok {
		return zh, &quot;zh&quot;, false
	}
	if en, ok := h.StoreEn[slug]; ok {
		return en, &quot;en&quot;, true
	}
	return nil, &quot;&quot;, false
}
</code></pre>

<p>配套的两个小函数在仓库完整实现里：<code>requestLang</code> 只把 <code>lang=en</code> 识别为英文请求，其余按默认中文处理；<code>hasTranslation</code> 判断该 slug 是否存在英文译文。单篇 handler 拿到解析结果后做<strong>浅拷贝</strong>再回填 <code>Lang/IsFallback/HasTranslation</code>，避免污染共享存储中的文档。</p>

<p>列表 <code>GET /api/docs</code> 的行为：</p>

<ul>
<li>默认返回摘要（<code>DocSummary</code>，不含 <code>Content</code>），支持 <code>page</code>/<code>limit</code> 分页、<code>search</code>/<code>tag</code> 过滤与 <code>group=series</code> 系列分组。只有显式传 <code>?content=true</code> 才返回含正文的完整 <code>Doc</code>。</li>
<li>排序按日期倒序；日期并列时用确定性次级规则（同系列按 <code>order</code> 降序、其余按 slug 升序），防止 map 随机迭代顺序导致每次请求顺序不同、引发 SSR 与客户端水合不一致。</li>
<li><code>group=series</code> 时先按系列分组，再按阅读顺序排序（显式 <code>order</code> 优先、未声明按日期升序兜底，见 <code>orderSeriesDocs</code>）。</li>
</ul>

<h2 id="八-内容如何上线">八、内容如何上线</h2>

<p>拆出 Go 服务后，发布链路分成两条：</p>

<ul>
<li><strong>前端</strong>：只消费 API 返回的 JSON，内容改动不再触发 Nuxt 构建——原流程里&rdquo;3-4 分钟&rdquo;的那一段消失了；</li>
<li><strong>内容与后端</strong>：content/ 与代码同仓，改动提交到 main 后触发 GitHub Actions 跑测试、构建镜像并部署——Dockerfile 会把 content/ 一并打进镜像。服务启动时一次性加载全部内容。</li>
</ul>

<p>所以当前的真实边界是：更新文章不再需要&rdquo;重建前端&rdquo;，但仍要经过一次后端镜像的 CI 构建与部署（内容同时获得 git 版本管理）。换来的收益是前端构建产物与内容彻底解耦，前端与内容的发布节奏可以各自独立。若想进一步做到&rdquo;只同步 Markdown 文件&rdquo;，把 content/ 从镜像中移出、改为服务器卷挂载后重启进程即可——服务没有热重载，内容改动需重启生效，这是当前最值得做的下一步演进。</p>

<p>生产化还有三件小事，本文没有展开（当前量级也不需要）：</p>

<ul>
<li><strong>容器崩溃自愈</strong>：<code>restart: always</code>（Docker）或 systemd 托管，进程挂了自动拉起；</li>
<li><strong>可观测性</strong>：开一个 <code>net/http/pprof</code> 端点，就能用 §9 那套内存口径在线上定位增长——小流量下 gin 默认 logger 已够用，暂不需要结构化日志；</li>
<li><strong>零停机发布</strong>：先起新实例、验过 <code>/health</code> 再切流——当前 CI 是 <code>docker compose up --force-recreate</code> 直接重建，存在一个重启窗口。</li>
</ul>

<p>这个窗口的实际影响很小：服务是纯读 API、无进行中写入。滚动更新 / 多副本（K8s 或 <code>--scale</code> + 负载均衡）是另一个复杂度台阶，收益撑不起成本——单机部署是刻意的取舍。</p>

<h2 id="九-运行数据与压力实测">九、运行数据与压力实测</h2>

<h3 id="9-1-复现测量-当前仓库">9.1 复现测量（当前仓库）</h3>

<p>在仓库当前实现与真实 <code>content/</code> 上复测：</p>

<table>
<thead>
<tr>
<th>指标</th>
<th>实测值</th>
</tr>
</thead>

<tbody>
<tr>
<td>文章规模</td>
<td>107 个 <code>.md</code>（docs 104 = 中文 69 + 英文 35；about 3），≈ 1.64 MiB</td>
</tr>

<tr>
<td>加载耗时</td>
<td>8 次连测：min / avg / max ≈ 41.5 / 44.7 / 46.4 ms</td>
</tr>

<tr>
<td>内存（加载后）</td>
<td>RSS 增量 ≈ 8.8 MB（基线 10.5 → 19.3 MB）；VmHWM ≈ 19.3 MB；Heap sys / alloc ≈ 15.5 / 2.8 MB</td>
</tr>
</tbody>
</table>
<p><strong>API 响应</strong>（进程内 gin + <code>httptest</code>，各 200 次，含 JSON 序列化、不含网络）：</p>

<table>
<thead>
<tr>
<th>接口</th>
<th>avg</th>
<th>p50</th>
</tr>
</thead>

<tbody>
<tr>
<td>列表摘要 <code>GET /api/docs?lang=zh</code></td>
<td>≈ 28.1 µs</td>
<td>≈ 25.3 µs</td>
</tr>

<tr>
<td>单篇含正文 <code>GET /api/docs/:slug?lang=zh</code></td>
<td>≈ 27.0 µs</td>
<td>≈ 25.1 µs</td>
</tr>
</tbody>
</table>

<h3 id="9-2-压力测试-这套做法能装下多少文章">9.2 压力测试：这套做法能装下多少文章</h3>

<p>用合成数据把规模推到 2 万篇，量化&rdquo;全量载入 + 启动解析&rdquo;方案的上限（合成文档 ≈16 KB/篇、frontmatter 结构同真实文章；环境同 9.1，每档独立进程）：</p>

<table>
<thead>
<tr>
<th>合成规模 N</th>
<th>加载耗时（3 次均值）</th>
<th>RSS 增量</th>
<th>每篇 RSS 均摊</th>
<th>列表 API p50</th>
</tr>
</thead>

<tbody>
<tr>
<td>500</td>
<td>≈ 136 ms</td>
<td>≈ 21 MB</td>
<td>≈ 43 KB</td>
<td>≈ 77 µs</td>
</tr>

<tr>
<td>1,000</td>
<td>≈ 232 ms</td>
<td>≈ 37 MB</td>
<td>≈ 38 KB</td>
<td>≈ 162 µs</td>
</tr>

<tr>
<td>2,000</td>
<td>≈ 449 ms</td>
<td>≈ 69 MB</td>
<td>≈ 35 KB</td>
<td>≈ 357 µs</td>
</tr>

<tr>
<td>5,000</td>
<td>≈ 1.08 s</td>
<td>≈ 170 MB</td>
<td>≈ 35 KB</td>
<td>≈ 1.02 ms</td>
</tr>

<tr>
<td>10,000</td>
<td>≈ 2.07 s</td>
<td>≈ 328 MB</td>
<td>≈ 34 KB</td>
<td>≈ 2.29 ms</td>
</tr>

<tr>
<td>20,000</td>
<td>≈ 4.13 s</td>
<td>≈ 662 MB</td>
<td>≈ 34 KB</td>
<td>≈ 5.10 ms</td>
</tr>
</tbody>
</table>
<p>读法：</p>

<ul>
<li><strong>加载耗时基本线性</strong>：约 0.21 ms/篇——数千篇冷启动秒级以内，2 万篇约 4 s。</li>
<li><strong>内存稳态均摊 ≈ 34 KB/篇</strong>：500 篇约 43 KB（含一次性开销），真实 104 篇小样本 ≈ 87 KB/篇（固定开销占比大）。按（预算 − 基线 ≈ 20 MB）÷ 0.034 MB/篇：512 MB ≈ 1.4 万篇、1 GB ≈ 3 万篇、2 GB ≈ 6 万篇。</li>
<li><strong>列表接口会全量排序</strong>：默认摘要列表 p50 从 500 篇的 ≈ 77 µs 升到 2 万篇的 ≈ 5.1 ms（单篇查询仍是 O(1)）。</li>
</ul>

<p>结论：这套&rdquo;启动全量解析 + 内存 map&rdquo;的做法在个人博客/文档站量级（≤ 数千篇）下内存几十到几百 MB、冷启动秒级以内，余量非常充足。按 512 MB 预算约可承载 1.4 万篇（实测 1 万篇 ≈ 0.35 GB）；2 万篇实测 ≈ 0.68 GB，需要 1 GB 档。此量级下列表全量排序开始出现毫秒级成本，若目标到数万篇，再考虑懒加载、摘要与正文分离或索引化等演进。</p>

<blockquote>
<p>测量方法如下。9.1 针对真实 content：规模用 <code>find content -name &quot;*.md&quot; | wc -l</code> 与 <code>du -sb content</code> 统计；加载耗时取进程内多次 <code>time.Since</code>；内存读 <code>/proc/self/status</code>（VmRSS/VmHWM）与 <code>runtime.ReadMemStats</code>；API 延迟为进程内 gin + <code>httptest</code>，均不含网络。9.2 使用合成数据（正文 ≈ 16 KB/篇、8 字段 frontmatter），档位 500 → 20,000；每档新起进程、冷加载 3 次取均值，列表 p50 为预热 20 次后 100 次的进程内请求。环境均为 AMD Ryzen 5 5600X / 16 GB / Linux，Go 1.27.1。部署时间 ~10 秒为发布/CI 环境值，本地未复测；旧表&rdquo;&lt; 5MB&rdquo;为 39 篇时代的进程占用口径。</p>
</blockquote>

<h2 id="十-适用边界-什么时候用它-什么时候上数据库">十、适用边界：什么时候用它，什么时候上数据库</h2>

<p>前两节实测回答了&rdquo;这套方案能装多少&rdquo;，这里回答&rdquo;该不该用它&rdquo;。判断基准不是文章数量，而是数据性质——<strong>你的内容是内容，还是业务数据</strong>：前者只读、低频、作者可控，适合本文方案甚至静态生成；后者需要写入、实时与灵活查询，才真正需要数据库。</p>

<table>
<thead>
<tr>
<th>判断维度</th>
<th>继续用本文的内存加载方案</th>
<th>换数据库 / 全文索引</th>
</tr>
</thead>

<tbody>
<tr>
<td>内容性质</td>
<td>只读为主、作者自产，正文即最终数据</td>
<td>用户产生或运行期写入：评论、草稿、多作者、UGC</td>
</tr>

<tr>
<td>更新方式</td>
<td>低频，随发布流程（git + CI）重启生效，接受重启窗口</td>
<td>高频 / 实时，不能等发布与重启</td>
</tr>

<tr>
<td>查询形态</td>
<td>固定几种：按 slug 单篇、列表分页/标签/标题摘要关键词、语言回退</td>
<td>正文全文检索 + 相关性排序、临时组合查询、统计报表</td>
</tr>

<tr>
<td>规模</td>
<td>数千～1-2 万篇：512 MB ≈ 1.4 万、1 GB ≈ 3 万（§9.2）</td>
<td>数万篇以上或单篇体积巨大，内存 / 冷启动预算吃紧</td>
</tr>

<tr>
<td>写入与权限</td>
<td>无并发写入，不需要事务与审计</td>
<td>需要事务、并发控制、权限分级与审计</td>
</tr>

<tr>
<td>运维成本</td>
<td>零迁移零备份，行为全部可控</td>
<td>迁移、备份、连接管理的持续成本（常被低估）</td>
</tr>
</tbody>
</table>
<p>怎么读这张表：<strong>右侧前两行是硬信号</strong>——出现运行期写入或实时更新需求，无论规模大小都该考虑数据库。<strong>查询与规模是软信号</strong>，只有吃紧时才轮到数据库 / 全文索引更省事。后两行则提醒你为右侧方案付出的运维税。个人博客通常六行全落在左侧：内容只读、改动随发布、查询固定、量级数千篇——此时内存 map 反而是比数据库更优的答案，正好兑现 §2 的三个目标。</p>

<p>还有一个容易撞到的天花板：本文方案的 <code>search</code> 只是对标题/摘要做内存 <code>contains</code>，<strong>没有正文全文检索与相关性排序</strong>。一旦需要正文级搜索、拼写容错或标签权重，就该引入全文索引（Meilisearch、Postgres FTS 或外部搜索服务），而不是继续在 loader 里手写遍历。</p>

<p>最后，二者不是二选一。Markdown 可以始终留在 git 里当唯一事实来源（编辑、评审、双语配对都不变），只把元数据或检索下沉到数据库 / 索引，正文 HTML 仍由 loader 生成并缓存。本文的&rdquo;全量载入内存&rdquo;只是谱系的一端，懒加载 + 缓存、摘要与正文分离、外置索引都是按需演进的中间站。其中最容易先做的是<strong>摘要与正文分离</strong>：启动只载 frontmatter 元数据，正文 HTML 按 slug 首次命中再解析并缓存。它会引入两个新成本——singleflight 防并发重复解析，以及解析失败从启动期警告移到请求期；取舍随之变化。</p>

<p>不过对博客场景，它并非明显的优化。每篇正文最终都会被读到，懒加载的稳态内存会收敛到与全量载入相当的水平。它真正省的只有冷启动时间，代价是把解析失败从启动期警告移到请求期，还引入并发去重。真正划算的场景是正文访问远少于元数据的长尾档案库——那不是博客的形态。</p>

<h2 id="十一-已知约束与演进">十一、已知约束与演进</h2>

<p>下面这些条目都是<strong>已意识到的边界，刻意不修</strong>：当前均未触发，逐个修会稀释本文&rdquo;记录当前实现&rdquo;的主线——因此只在此登记边界与对应的最小修法，哪个先触发再改。</p>

<table>
<thead>
<tr>
<th>边界</th>
<th>现状与影响</th>
<th>修法 / 约定</th>
</tr>
</thead>

<tbody>
<tr>
<td>Frontmatter 切分非行锚定</td>
<td>正文靠前出现 <code>---</code>（水平线、setext 标题下划线）可能错位；当前靠&rdquo;frontmatter 紧贴文件开头&rdquo;约定规避</td>
<td>行扫描：首行（容忍 BOM）恰为 <code>---</code>，以第一个&rdquo;整行恰好是 <code>---</code>&ldquo;的行为关闭定界符；或引入 frontmatter 解析库</td>
</tr>

<tr>
<td>Slug 只取 basename</td>
<td>子目录同名文件会静默互相覆盖；<code>x.en.md</code> 与任意目录的 <code>x.md</code> 配对；loader 未做冲突检测</td>
<td>命名约定：basename 全局唯一（当前已满足）；先触发再加冲突检测 / 警告</td>
</tr>

<tr>
<td>渲染未做清理</td>
<td>原始 HTML 透传，<code>HrefTargetBlank</code> 无 <code>rel=&quot;noopener noreferrer&quot;</code></td>
<td>自管内容可接受；开放投稿前补 sanitize 与 noopener</td>
</tr>

<tr>
<td>API 信封不统一</td>
<td>列表返回 <code>{data,total,...}</code>，系列分组与 about 列表返回裸数组</td>
<td>前端分别处理；收口信封是破坏性变更，需前后端同步改——自用 API、无第三方消费者，统一收口的收益低于成本，暂缓</td>
</tr>

<tr>
<td>图片与静态资源（刻意回避）</td>
<td>正文尽量不放图，避免资源托管 / CDN 等外部依赖；loader 只产出 HTML</td>
<td>必须配图时用绝对 URL 指向已有图床，不做资源搬迁</td>
</tr>
</tbody>
</table>

<blockquote>
<p>提示：goccy 的 YAML 解析容忍开头的 <code>---</code> 文档头（见其解码测试），但它只省掉第一个分隔符。第二个 <code>---</code> 之后是 Markdown 正文，不能交给 YAML 解析器——所以切分必须自己做。</p>
</blockquote>

<h2 id="十二-小结">十二、小结</h2>

<p>至此，一个由 Go 实现的轻量 Markdown 数据源就完成了：启动时把 content/ 解析进内存，通过 Gin 暴露列表与单篇接口；前端只消费 JSON，与内容解耦。相比 Nuxt Content 全家桶，这套方案的依赖与行为都更可控；代价（内容随镜像发布、无热重载、若干解析边界）已在上文如实列出——知道自己停在哪，是自建方案的一部分。</p>

<hr>

<blockquote>
<p>注：正文只展示关键代码节选，完整实现见<a href="https://github.com/yuelinghuashu/moongate-api" target="_blank">仓库</a> <code>internal/</code> 下的 <code>domain</code>、<code>loader</code>、<code>api</code> 三包与根目录 <code>main.go</code>。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[记录如何把博客 Markdown 内容加载从 Nuxt Content 中拆出，用 Go 实现独立数据 API：动机、双语内存存储与语言回退、内容上线路径、实测数据、适用边界（何时该上数据库）与已知约束（完整代码见仓库）。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[Nuxt SSR 内存泄漏排查实录：一台 2G 服务器被拖垮的完整复盘]]></title>
      <link>https://moongate.top/docs/nuxt-ssr-memory-leak-troubleshooting</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-ssr-memory-leak-troubleshooting</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-现象-磁盘告警-ssh-卡死">一、现象：磁盘告警，SSH 卡死</h2>

<p>某天，我的服务器收到一条云厂商告警：</p>

<table>
<thead>
<tr>
<th>字段</th>
<th>内容</th>
</tr>
</thead>

<tbody>
<tr>
<td>事件名称</td>
<td>Instance:StoragePerformanceReachLimit:Executed</td>
</tr>

<tr>
<td>等级</td>
<td>WARN</td>
</tr>

<tr>
<td>原因码</td>
<td>SysDiskBPS</td>
</tr>

<tr>
<td>地域</td>
<td>cn-beijing</td>
</tr>
</tbody>
</table>
<p>与此同时，SSH 完全无法登录，连接一直卡住超时。</p>

<p>第一反应是检查磁盘空间是否满了——毕竟告警写着&rdquo;StoragePerformanceReachLimit&rdquo;。但磁盘明明没满，为什么会报磁盘告警？SSH 又为什么连不上？</p>

<p>事后才搞清楚一个关键认知差：<strong>磁盘性能告警 ≠ 磁盘空间满</strong>。云厂商的磁盘监控有两类指标，一类是容量（用了多少空间），另一类是性能（读写速度、IOPS）。这次的 <code>SysDiskBPS</code> 属于后者，监控的是磁盘<strong>读写吞吐</strong>是否达到上限。</p>

<p>而磁盘吞吐被跑满的根因，藏得很深——它不在服务器配置里，不在容器设置里，而在我的 Nuxt.js 应用代码里。</p>

<h2 id="二-系统层初步排查-swap-与内存">二、系统层初步排查：Swap 与内存</h2>

<p>SSH 无法连接，直接强制重启。重启后查看系统资源：</p>

<pre><code class="language-bash">free -h
</code></pre>

<p>输出显示 <code>Swap</code> 使用量异常偏高，物理内存（<code>Mem</code>）所剩无几。</p>

<h3 id="swap-是什么">Swap 是什么？</h3>

<p>物理内存不足时，Linux 内核把暂时不用的数据从内存挪到磁盘，这个机制叫 Swap。磁盘远慢于内存，一旦系统频繁使用 Swap，响应急剧变慢；大量磁盘读写又会撑高 BPS，触发云厂商的性能告警。</p>

<p>所以链路是：<strong>内存不足 → 触发 Swap → 磁盘性能被拖垮 → 触发 BPS 告警 → 系统卡死、SSH 无法响应。</strong></p>

<h3 id="为什么连-ssh-都会卡死">为什么连 SSH 都会卡死？</h3>

<p>内存极度紧张时，Linux 内核触发<strong>直接内存回收（Direct Reclaim）</strong>，需要申请内存的进程必须同步等待内核回收内存，进入<strong>不可中断睡眠状态（D 状态）</strong>。具体到 SSH：</p>

<ol>
<li><strong>fork() 阻塞</strong>：SSH 连接建立后，sshd 需要 fork() 子进程执行 bash，而 fork() 需要分配内存，触发 Direct Reclaim 后被卡住。</li>
<li><strong>网络栈响应缓慢</strong>：kswapd0 等内核线程占用大量 CPU，网络中断处理延迟，TCP 连接超时。</li>
</ol>

<p>此时如果能登录（比如 VNC），可以用 <code>ps aux | grep &quot; D &quot;</code> 看到大量 D 状态进程；<code>top</code> 中 <code>%sy</code>（系统态 CPU）会异常偏高。</p>

<h3 id="用-vmstat-iostat-确认-swap-风暴">用 vmstat/iostat 确认 Swap 风暴</h3>

<p>健康状态（优化后）：</p>

<pre><code class="language-bash"># vmstat 1 输出（关键列）
procs -----------memory---------- ---swap-- -----io----
 r  b   swpd   free   buff  cache   si   so    bi    bo
 1  0      0  76676   3088 323184    0    0   191    30
</code></pre>

<ul>
<li><code>swpd=0</code>：未使用 Swap；<code>si/so=0</code>：无换入换出。</li>
</ul>

<p>危险状态（故障时）：</p>

<pre><code class="language-bash">procs -----------memory---------- ---swap-- -----io----
 r  b   swpd   free   buff  cache   si   so    bi    bo
 5  2 524288    128     64   1024 1500 2000  3000  4000
</code></pre>

<ul>
<li><code>swpd</code> 高位、<code>si/so</code> 很大：系统正在疯狂换页，磁盘被拖垮。</li>
</ul>

<p>到这里，我的判断还停留在&rdquo;2G 内存不够用、容器太多&rdquo;。真相远不止于此。</p>

<h2 id="三-容器层-发现-嫌疑">三、容器层：发现&rdquo;嫌疑&rdquo;</h2>

<p>服务器上跑着一个 Nuxt.js 博客应用（容器 <code>my-blog-app</code>）和其他几个容器。用 <code>docker stats</code> 观察，发现 <code>my-blog-app</code> 的 RSS 异常且持续增长。</p>

<p>当时做了一些常规缓解：删除不再需要的 PostgreSQL 容器、给各容器设置 <code>--memory</code> 上限并禁用 Swap。这些措施确实让系统暂时稳定，但<strong>内存仍在缓慢增长</strong>——它们只是把症状按住，没有解决根因。（这也是本文最想强调的一点：容器层面的限制是&rdquo;治标&rdquo;，真正的病在应用代码里。）</p>

<h2 id="四-真相一-内存根本不在-node-堆里">四、真相一：内存根本不在 Node 堆里</h2>

<p>容器内存一路涨到 859 MiB，但进入容器检查 Node.js 的堆内存，结果令人意外：</p>

<pre><code class="language-bash">/app # node -e &quot;console.log(process.memoryUsage())&quot;
{
  rss: 48873472,        // ~48 MiB，进程物理内存
  heapTotal: 6062080,   // ~6 MiB
  heapUsed: 4066256,    // ~4 MiB
  ...
}
</code></pre>

<p>再看 <code>/proc/1/status</code>：</p>

<pre><code class="language-bash">VmPeak: 18706992 kB   // 峰值虚拟内存：17.8 GB！
VmRSS:  896188 kB     // 当前物理内存：875 MiB
VmData: 1490800 kB    // 数据段：1.45 GB
Threads: 11
</code></pre>

<p><strong>关键矛盾出现了</strong>：容器显示 859 MiB、进程 RSS 也是 875 MiB，但 V8 堆只用了几 MiB。内存不在堆里——它被服务端运行时的某个&rdquo;看不见的东西&rdquo;持续累积，峰值虚拟内存甚至冲到 17.8 GB。</p>

<p>这排除了&rdquo;业务代码产生大对象&rdquo;的猜测，把矛头指向<strong>框架/运行时层的全局状态</strong>。</p>

<h2 id="五-真相二-nuxt-ssr-模块级全局状态泄漏">五、真相二：Nuxt SSR 模块级全局状态泄漏</h2>

<p>排查到 <code>app/composables/useRouteQuery.ts</code>，发现了一个典型的 SSR 内存泄漏模式——<strong>模块级可变全局状态</strong>：</p>

<pre><code class="language-typescript">// ❌ 问题代码
const registry = new Set&lt;QueryRegistration&gt;()   // 模块级全局 Set

function useRouteQueryRaw(name: string) {
  const value = ref(route.query[name])
  const registration = { name, getValue: () =&gt; value.value }
  registry.add(registration)                     // 每次调用都添加

  const instance = getCurrentInstance()
  if (instance) {
    onUnmounted(() =&gt; registry.delete(registration))  // 依赖卸载清理
  }
  ...
}
</code></pre>

<p><strong>泄漏机制</strong>：</p>

<ol>
<li>每次 SSR 请求渲染 <code>/docs</code> 页面，<code>useDocs()</code> 会调用多个 <code>useRouteQuery*</code>，每个都向模块级 <code>registry</code> Set 添加一个注册项，注册项闭包持有 ref 引用。</li>
<li>清理依赖 <code>onUnmounted</code>——但<strong>服务端渲染没有卸载生命周期</strong>，注册项永远不会被删除。</li>
<li>于是注册项跨请求无限累积，每个都拽着一整条响应式引用链，内存随之无上限增长。VmPeak 冲到 17.8 GB 就是累积的结果。</li>
</ol>

<h2 id="六-修复-让全局注册表随请求释放">六、修复：让全局注册表随请求释放</h2>

<p>把模块级 Set 改成<strong>以 nuxtApp 为 key 的 WeakMap</strong>：</p>

<pre><code class="language-typescript">// ✅ 修复代码
const registryMap = new WeakMap&lt;object, Set&lt;QueryRegistration&gt;&gt;()

function getRegistry() {
  const nuxtApp = useNuxtApp()
  let registry = registryMap.get(nuxtApp)
  if (!registry) {
    registry = new Set()
    registryMap.set(nuxtApp, registry)
  }
  return registry
}
</code></pre>

<p>原理：</p>

<ul>
<li><strong>服务端</strong>：每个请求有独立的 nuxtApp → 每个请求有独立注册表，不再跨请求累积。</li>
<li><strong>客户端</strong>：全局唯一 nuxtApp → 所有组件共享同一个注册表，<code>resetFilters</code> 批量更新 URL 的行为不受影响。</li>
</ul>

<p><strong>为什么 WeakMap 能做到&rdquo;随请求释放&rdquo;？</strong> 关键在于 WeakMap 的 key 是<strong>弱引用</strong>。服务端每个请求结束后，Nuxt 框架会释放该请求的 nuxtApp（不再被强引用），下一次 GC 时 WeakMap 中对应的整条条目——包括它的 value（那个注册表 Set）——就会一并被回收。所以注册表天然跟着请求的生死走，不需要手动清理。</p>

<p><strong>那客户端呢？</strong> 客户端的机制正好相反：nuxtApp 全局唯一且常驻，注册表不会自动消失，靠的是<strong>组件卸载时清理</strong>——路由切换导致组件卸载，<code>onUnmounted</code> 触发，把该组件添加的注册项从注册表删除。这样客户端一侧靠&rdquo;手动清理&rdquo;维持平衡，也不会随页面切换而膨胀。服务端自动释放、客户端手动清理，两套机制恰好对称。</p>

<p>还有一个隐蔽的坑：<code>watch</code> 回调在异步执行时 <code>getCurrentInstance()</code> 返回 null，原代码在 watch 里动态查注册表会失败（表现为状态改了 URL 不更新）。修复方式是在 setup 期间<strong>捕获注册表引用</strong>，供 watch 闭包使用：</p>

<pre><code class="language-typescript">function useRouteQueryRaw(name: string) {
  const registry = getRegistry() // setup 期捕获
  if (registry) {
    registry.add({ name, getValue: () =&gt; value.value })
    // ...onUnmounted 清理（客户端）
  }
  watch(value, () =&gt; {
    router.replace({ query: buildQueryFromRegistry(registry) }) // 用捕获的引用
  })
  return value
}
</code></pre>

<h2 id="七-验证-内存稳定了">七、验证：内存稳定了</h2>

<p>修复后重新部署，对比非常直观：</p>

<table>
<thead>
<tr>
<th>指标</th>
<th>修复前</th>
<th>修复后</th>
</tr>
</thead>

<tbody>
<tr>
<td>启动内存</td>
<td>~60 MiB</td>
<td>~62 MiB</td>
</tr>

<tr>
<td>峰值/趋势</td>
<td>859 MiB 且无上限（~6 MiB/分钟）</td>
<td>30 分钟后 152 MiB</td>
</tr>

<tr>
<td>长期趋势</td>
<td>持续增长直至 OOM</td>
<td><strong>20 分钟后稳定在 ~150 MiB，不再增长</strong></td>
</tr>

<tr>
<td>VmPeak</td>
<td>17.8 GB</td>
<td>回归正常</td>
</tr>
</tbody>
</table>
<p>同时验证了功能没有回归：</p>

<ul>
<li><strong>状态 → URL</strong>：搜索、翻页、点标签后 URL 正常更新。</li>
<li><strong>URL → 状态</strong>：手动改 URL，筛选状态同步。</li>
<li><strong>跨组件共享</strong>：标签筛选（列表页 + 筛选器）状态一致。</li>
</ul>

<h2 id="八-总结与经验">八、总结与经验</h2>

<h3 id="ssr-内存泄漏排查方法论">SSR 内存泄漏排查方法论</h3>

<p>这次排查走了一条清晰的链路，任何 SSR 应用都可以复用：</p>

<ol>
<li><strong>容器层</strong>（<code>docker stats</code>）：发现哪个进程内存异常。</li>
<li><strong>进程层</strong>（<code>/proc/1/status</code>）：看 RSS、VmPeak、VmData——RSS 与堆差距大、VmPeak 异常高，都是强信号。</li>
<li><strong>运行时层</strong>（<code>process.memoryUsage()</code>）：确认问题不在 V8 堆里。</li>
<li><strong>代码层</strong>：搜索模块级可变全局状态（模块级 <code>let</code>、<code>const xxx = new Set()/Map()/[]</code>），SSR 下它们会在请求间共享、累积。</li>
</ol>

<h3 id="三条教训">三条教训</h3>

<ol>
<li><strong>模块级可变全局状态 = SSR 内存泄漏头号嫌疑</strong>。需要跨请求共享的状态，应放在请求作用域（Nuxt 的 nuxtApp / useState）里，而不是模块顶层。</li>
<li><strong>容器限制只是缓解，不是根因</strong>。给容器设 <code>--memory</code> 上限能防止拖垮整机，但内存泄漏还在——找到并修复根因才算真正解决。</li>
<li><strong>VmPeak 与 RSS 的巨大差距是泄漏的警报</strong>。正常进程的峰值虚拟内存不会比常驻内存高出几个数量级，出现这种情况先怀疑运行时层的累积。</li>
</ol>

<p>一次排查，从云厂商的一条磁盘告警，挖到了自家应用代码里的一行全局状态。表面是&rdquo;2G 服务器不够用&rdquo;，实际是代码让 2G 服务器怎么都不够用。问题的本质不是内存太小，而是<strong>无上限累积</strong>——即便当时升到 16G，也只是把崩溃从几小时延长到几天。扩容只能延缓症状，修复根因才是终点。希望这篇文章能帮你少走这些弯路。</p>
]]></content:encoded>
      <description><![CDATA[从云厂商磁盘告警与 SSH 卡死出发，逐层深入 Swap、容器、Node 进程，最终定位到 Nuxt.js SSR 模块级全局状态导致的服务端内存泄漏，并给出修复与验证。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[SSR]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Performance]]></category>
      
    </item>

    <item>
      <title><![CDATA[记忆：多会话与轻量 RAG]]></title>
      <link>https://moongate.top/docs/memory-rag</link>
      <guid isPermaLink="true">https://moongate.top/docs/memory-rag</guid>
      <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>第 3 篇的多轮循环让 Agent 会干活了，但它每次启动都是“失忆”的。本篇补上两类记忆：<strong>跨会话事实</strong>（remember/recall 键值记忆，落盘 JSON）与<strong>资料检索</strong>（对本地笔记做向量检索的轻量 RAG）。</p>

<ul>
<li>前置：第 3 篇的多轮循环已跑通</li>
<li>准备：<code>ollama pull nomic-embed-text</code>（约 274MB；没有此模型时示例会自动降级为关键词检索）</li>
<li>形态说明：本篇沿用第 3 篇的<strong>命令行一次性运行</strong>（三个会话在同一个进程内顺序演示「会话隔离」），不扩展第 4 篇的 SSE 服务；要把记忆接入服务，把这里的工具表与循环搬进服务端即可</li>
</ul>

<h2 id="1-记忆分三种-别混为一谈">1. 记忆分三种，别混为一谈</h2>

<table>
<thead>
<tr>
<th>类型</th>
<th>是什么</th>
<th>存哪</th>
<th>本篇做法</th>
</tr>
</thead>

<tbody>
<tr>
<td>会话内记忆</td>
<td>本轮对话上下文</td>
<td>内存里的 messages 切片</td>
<td>前几篇一直在用</td>
</tr>

<tr>
<td>长期记忆</td>
<td>跨会话的事实（用户名、偏好）</td>
<td>外部存储</td>
<td>键值对写入本地 JSON 文件</td>
</tr>

<tr>
<td>资料记忆</td>
<td>本地文档/笔记，可检索</td>
<td>文档库 + 索引</td>
<td>向量检索 topK（轻量 RAG）</td>
</tr>
</tbody>
</table>
<p>前几篇的 Agent 每次启动都是&rdquo;失忆&rdquo;的：messages 清空就什么都不记得。本篇用两套工具补上后两类记忆：<code>remember</code>/<code>recall</code>/<code>list_memory</code>（长期事实）+ <code>search_notes</code>（资料检索）。（本篇说的&rdquo;会话隔离&rdquo;就是：每个会话各自持有自己的 <code>messages</code> 切片，A 的对话历史不会出现在 B 里；能跨会话共享的只有落盘的 <code>memory.json</code>。）</p>

<h2 id="2-长期记忆-键值事实-枚举能力">2. 长期记忆：键值事实 + 枚举能力</h2>

<p>工具设计上有三个细节，决定了记忆好不好用：</p>

<ol>
<li><strong>持久化</strong>：<code>remember</code> 把键值写进 <code>memory.json</code>，进程重启也不丢；</li>
<li><strong>key 用命名空间风格</strong>：<code>用户:名字</code>、<code>用户:语言</code>，避免不同主题撞键；</li>
<li><strong>必须有 <code>list_memory</code>（枚举）</strong>：键值记忆的前提是&rdquo;知道键名&rdquo;。新会话里模型不知道别人写过什么键，只靠 <code>recall</code> 猜键会失败（实测它会去猜 <code>用户:名字</code>、甚至传 <code>*</code>）；提供枚举工具后，它列出全部键值即可作答。</li>
</ol>

<h2 id="3-轻量-rag-embed-余弦-topk">3. 轻量 RAG：embed → 余弦 → topK</h2>

<p>先说一句白话：<strong>向量化（embedding）就是把一段文本交给模型换成一串数字（向量）</strong>——语义越近的文本，向量方向越接近，于是&rdquo;像不像&rdquo;这件事可以直接用余弦相似度算出来。</p>

<p><code>search_notes</code> 的实现思路就是最小 RAG：</p>

<pre><code>把问题(query) 和 每条笔记 分别向量化
→ 算 query 与每条笔记的余弦相似度
→ 取相似度 &gt;= 0.35 的 topK 片段
→ 把片段拼进工具结果，让模型&quot;依据笔记作答&quot;
</code></pre>

<p>后面两件事<strong>和常见直觉不一样</strong>，先给本机实测（Ollama 0.33.3 + <code>nomic-embed-text</code>，语料就是上面的 6 条笔记，数值为余弦相似度）：</p>

<table>
<thead>
<tr>
<th>查询</th>
<th>带前缀（<code>search_document:</code> / <code>search_query:</code>）</th>
<th>不带前缀</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>Intel Arc Ollama GPU 加速</code>（相关）</td>
<td>0.560 ~ 0.778</td>
<td>0.592 ~ 0.771</td>
</tr>

<tr>
<td><code>怎么做红烧肉？</code>（完全无关）</td>
<td>0.500 ~ 0.598（6/6 全部过 0.35）</td>
<td>0.465 ~ 0.530</td>
</tr>
</tbody>
</table>

<ul>
<li><strong>相似度阈值必须按自己的语料标定，照抄一个 0.35 往往会形同虚设</strong>：短文本、同主题的语料里，余弦分数会被压在很窄的高位带（本例 0.5~0.8），<strong>连完全无关的问题也能拿到 0.5 以上</strong>，于是 6 条笔记全部过阈——真正把片段收敛到 3 条的是 <code>topK</code>。阈值本身不是错的设计（语料差异大、文档长时分数才会散开），但它得配着自己的语料调；本篇保留 <code>&gt;= 0.35</code> 只为演示写法，<strong>别照抄这个数字</strong>。</li>
<li><strong>embedding 前缀是模型自己的约定，建议遵守，但别指望它单独解决问题</strong>：<code>nomic-embed-text</code> 要求文档加 <code>search_document:</code>、查询加 <code>search_query:</code>；本机实测加与不加差别很小（最高分 0.778 vs 0.771，排序几乎不变），语料差异更大时才会显出来。相比前缀，<strong>阈值标定、片段切分与 topK 的影响通常更大</strong>。</li>
</ul>

<h2 id="4-完整代码">4. 完整代码</h2>

<details>
<summary>main.go 全文（点击展开）</summary>

<pre><code class="language-go">// 第 5 篇演示：记忆与轻量 RAG
//
// 本篇把前几篇的 Agent 循环升级成&quot;有记忆&quot;的形态：
//  1. 跨会话长期记忆：remember/recall 工具，事实写入本地 JSON 文件，
//     换个会话（甚至重启进程）后依然能想起来；
//  2. 轻量 RAG：search_notes 工具对内置的&quot;运维笔记&quot;做检索——
//     有 nomic-embed-text 时用向量余弦相似度，没有则自动降级关键词打分；
//  3. 会话隔离：每个会话独立维护自己的 messages 历史，互不污染。
//
// 运行：go run main.go（在本文件所在目录执行）
package main

import (
	&quot;bytes&quot;
	&quot;encoding/json&quot;
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;math&quot;
	&quot;net/http&quot;
	&quot;os&quot;
	&quot;strings&quot;
	&quot;time&quot;
)

// ===========================================
// 与 /v1/chat/completions 对应的结构
// ===========================================

type Message struct {
	Role       string     `json:&quot;role&quot;`
	Content    string     `json:&quot;content,omitempty&quot;`
	ToolCalls  []ToolCall `json:&quot;tool_calls,omitempty&quot;`
	ToolCallID string     `json:&quot;tool_call_id,omitempty&quot;`
}

type ToolCall struct {
	ID       string `json:&quot;id&quot;`
	Type     string `json:&quot;type&quot;`
	Function struct {
		Name      string `json:&quot;name&quot;`
		Arguments string `json:&quot;arguments&quot;`
	} `json:&quot;function&quot;`
}

type Tool struct {
	Type     string `json:&quot;type&quot;`
	Function struct {
		Name        string         `json:&quot;name&quot;`
		Description string         `json:&quot;description&quot;`
		Parameters  map[string]any `json:&quot;parameters&quot;`
	} `json:&quot;function&quot;`
}

type ChatRequest struct {
	Model       string    `json:&quot;model&quot;`
	Messages    []Message `json:&quot;messages&quot;`
	Tools       []Tool    `json:&quot;tools,omitempty&quot;`
	Stream      bool      `json:&quot;stream&quot;`
	Temperature float64   `json:&quot;temperature&quot;`
}

type Choice struct {
	Message      Message `json:&quot;message&quot;`
	FinishReason string  `json:&quot;finish_reason&quot;`
}

type ChatResponse struct {
	Choices []Choice `json:&quot;choices&quot;`
}

// ===========================================
// 记忆存储（跨会话，持久化到文件）
// ===========================================

type memoryStore struct {
	path string
	data map[string]string
}

func loadMemory(path string) *memoryStore {
	m := &amp;memoryStore{path: path, data: map[string]string{}}
	if b, err := os.ReadFile(path); err == nil {
		_ = json.Unmarshal(b, &amp;m.data)
	}
	return m
}

func (m *memoryStore) set(key, value string) error {
	m.data[key] = value
	b, _ := json.MarshalIndent(m.data, &quot;&quot;, &quot;  &quot;)
	return os.WriteFile(m.path, b, 0o644)
}

// ===========================================
// 笔记库（模拟&quot;本地资料&quot;）与检索器
// ===========================================

var notes = []string{
	&quot;Ollama 默认只识别 NVIDIA(CUDA) 与 AMD(ROCm) 显卡；Intel Arc 需要在 systemd override 里设置 OLLAMA_VULKAN=true 并重启服务，才会走 Vulkan 后端。&quot;,
	&quot;验证 Vulkan 是否生效：journalctl -u ollama --no-pager | grep &apos;inference compute&apos;，看到 library=Vulkan 与 Arc A770 的描述即为成功。&quot;,
	&quot;OLLAMA_LOAD_TIMEOUT 默认 5 分钟，控制模型加载停滞多久后放弃；驱动慢或首次编译着色器时可调大，例如 10m。&quot;,
	&quot;OLLAMA_KEEP_ALIVE 默认 5 分钟，控制模型空闲多久后卸载；频繁调用可调大到 10m 以减少重复加载。&quot;,
	&quot;Vulkan 后端在部分 Linux 内核 + Mesa 驱动下有显存记账失步、空闲显存被换出的已知问题，长时间运行要用 intel_gpu_top 或 journalctl 监控。&quot;,
	&quot;llama3.1:8b 在 Intel Arc A770 上实测 33/33 层全量 offload，模型占显存约 4.4GB，生成速度约 41 tokens/s。&quot;,
}

// retriever 负责给 query 找最相关的笔记片段。
type retriever interface {
	search(query string, topK int) []searchHit
}

type searchHit struct {
	text  string
	score float64
}

// vectorRetriever 用 /api/embed 的向量做余弦相似度。
// nomic-embed-text 需要标准前后缀才能发挥效果：文档加 &quot;search_document:&quot;、
// 查询加 &quot;search_query:&quot;（这是 embedding 模型自己的约定）。
type vectorRetriever struct{}

func (vectorRetriever) search(query string, topK int) []searchHit {
	texts := make([]string, 0, len(notes)+1)
	texts = append(texts, &quot;search_query: &quot;+query)
	for _, n := range notes {
		texts = append(texts, &quot;search_document: &quot;+n)
	}
	vecs, err := embedTexts(texts)
	if err != nil {
		fmt.Println(&quot;   ⚠️ 向量检索失败，本次降级为空结果:&quot;, err)
		return nil
	}
	if len(vecs) != len(texts) { // 返回数量与输入不一致时避免越界
		fmt.Println(&quot;   ⚠️ 向量检索返回数量不符，本次降级为空结果&quot;)
		return nil
	}
	q := vecs[0]
	var hits []searchHit
	for i, n := range notes {
		s := cosine(q, vecs[i+1])
		if s &gt;= 0.35 { // 阈值太低会把无关片段也捞进来
			hits = append(hits, searchHit{n, s})
		}
	}
	return top(hits, topK)
}

// keywordRetriever 无 embedding 模型时的降级：按命中的词数打分。
type keywordRetriever struct{}

func (keywordRetriever) search(query string, topK int) []searchHit {
	words := tokenize(query)
	var hits []searchHit
	for _, n := range notes {
		score := 0.0
		lower := strings.ToLower(n) // 英文大小写不敏感（如 intel ↔ Intel）
		for _, w := range words {
			if strings.Contains(lower, w) {
				score++
			}
		}
		if score &gt; 0 {
			hits = append(hits, searchHit{n, score})
		}
	}
	return top(hits, topK)
}

func tokenize(s string) []string {
	// 中文按 2 字滑窗切分 + 英文按空白切分，足够演示用
	var out []string
	runes := []rune(s)
	for i := 0; i+1 &lt; len(runes); i++ {
		if runes[i] &lt; 128 &amp;&amp; runes[i+1] &lt; 128 {
			continue
		}
		out = append(out, string(runes[i:i+2]))
	}
	for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !(r &gt;= &apos;a&apos; &amp;&amp; r &lt;= &apos;z&apos; || r &gt;= &apos;A&apos; &amp;&amp; r &lt;= &apos;Z&apos; || r &gt;= &apos;0&apos; &amp;&amp; r &lt;= &apos;9&apos;) }) {
		if len(w) &gt; 1 {
			out = append(out, strings.ToLower(w))
		}
	}
	return out
}

func top(hits []searchHit, k int) []searchHit {
	for i := 1; i &lt; len(hits); i++ {
		for j := i; j &gt; 0 &amp;&amp; hits[j].score &gt; hits[j-1].score; j-- {
			hits[j], hits[j-1] = hits[j-1], hits[j]
		}
	}
	if len(hits) &gt; k {
		hits = hits[:k]
	}
	return hits
}

func cosine(a, b []float64) float64 {
	var dot, na, nb float64
	for i := range a {
		dot += a[i] * b[i]
		na += a[i] * a[i]
		nb += b[i] * b[i]
	}
	if na == 0 || nb == 0 {
		return 0
	}
	return dot / (math.Sqrt(na) * math.Sqrt(nb))
}

// ===========================================
// 工具注册
// ===========================================

type tool struct {
	description string
	parameters  map[string]any
	run         func(args json.RawMessage) (string, error)
}

var (
	mem                = loadMemory(&quot;memory.json&quot;)
	retrieve retriever = newRetriever()
)

func newRetriever() retriever {
	// 探测 /api/tags 里有没有 embedding 模型；有则用向量，没有降级关键词
	resp, err := http.Get(&quot;http://localhost:11434/api/tags&quot;)
	if err == nil {
		defer resp.Body.Close()
		var tags struct {
			Models []struct {
				Name string `json:&quot;name&quot;`
			} `json:&quot;models&quot;`
		}
		if json.NewDecoder(resp.Body).Decode(&amp;tags) == nil {
			for _, m := range tags.Models {
				if strings.Contains(m.Name, &quot;nomic-embed-text&quot;) {
					fmt.Println(&quot;📚 检测到 nomic-embed-text，启用向量检索&quot;)
					return vectorRetriever{}
				}
			}
		}
	}
	fmt.Println(&quot;📚 未检测到 embedding 模型，降级为关键词检索（可 ollama pull nomic-embed-text 升级）&quot;)
	return keywordRetriever{}
}

var tools = map[string]*tool{
	&quot;remember&quot;: {
		description: &quot;把一条事实写入长期记忆（跨会话保留）。key 用命名空间风格，如 用户:名字、项目:目标&quot;,
		parameters: map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{
			&quot;key&quot;:   map[string]any{&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;记忆的键，如 用户:名字&quot;},
			&quot;value&quot;: map[string]any{&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;记忆的内容&quot;},
		}, &quot;required&quot;: []string{&quot;key&quot;, &quot;value&quot;}},
		run: func(args json.RawMessage) (string, error) {
			var p struct {
				Key   string `json:&quot;key&quot;`
				Value string `json:&quot;value&quot;`
			}
			if err := json.Unmarshal(args, &amp;p); err != nil {
				return &quot;&quot;, fmt.Errorf(&quot;参数解析失败：%v&quot;, err)
			}
			if err := mem.set(p.Key, p.Value); err != nil {
				return &quot;&quot;, err
			}
			return fmt.Sprintf(&quot;已记住 %s = %s&quot;, p.Key, p.Value), nil
		},
	},
	&quot;recall&quot;: {
		description: &quot;从长期记忆里读取一条事实。key 与 remember 写入时一致；不确定键名时先用 list_memory 列出&quot;,
		parameters: map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{
			&quot;key&quot;: map[string]any{&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;记忆的键&quot;},
		}, &quot;required&quot;: []string{&quot;key&quot;}},
		run: func(args json.RawMessage) (string, error) {
			var p struct {
				Key string `json:&quot;key&quot;`
			}
			if err := json.Unmarshal(args, &amp;p); err != nil {
				return &quot;&quot;, fmt.Errorf(&quot;参数解析失败：%v&quot;, err)
			}
			if v, ok := mem.data[p.Key]; ok {
				return v, nil
			}
			return fmt.Sprintf(&quot;长期记忆中没有 %s 的记录&quot;, p.Key), nil
		},
	},
	&quot;list_memory&quot;: {
		description: &quot;列出长期记忆里所有的键与值，用于不确定键名时查看&quot;,
		parameters:  map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{}},
		run: func(args json.RawMessage) (string, error) {
			if len(mem.data) == 0 {
				return &quot;长期记忆为空&quot;, nil
			}
			var sb strings.Builder
			for k, v := range mem.data {
				fmt.Fprintf(&amp;sb, &quot;%s = %s\n&quot;, k, v)
			}
			return strings.TrimSpace(sb.String()), nil
		},
	},
	&quot;search_notes&quot;: {
		description: &quot;检索本地运维笔记库，返回最相关的片段，用于回答需要查资料的问题&quot;,
		parameters: map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{
			&quot;query&quot;: map[string]any{&quot;type&quot;: &quot;string&quot;, &quot;description&quot;: &quot;要检索的问题或关键词&quot;},
		}, &quot;required&quot;: []string{&quot;query&quot;}},
		run: func(args json.RawMessage) (string, error) {
			var p struct {
				Query string `json:&quot;query&quot;`
			}
			if err := json.Unmarshal(args, &amp;p); err != nil {
				return &quot;&quot;, fmt.Errorf(&quot;参数解析失败：%v&quot;, err)
			}
			hits := retrieve.search(p.Query, 3)
			if len(hits) == 0 {
				return &quot;笔记库中没有找到相关内容。&quot;, nil
			}
			var sb strings.Builder
			sb.WriteString(&quot;（以下是检索到的笔记片段，请严格只依据这些内容回答，不要编造笔记里没有的细节）\n&quot;)
			for i, h := range hits {
				fmt.Fprintf(&amp;sb, &quot;[片段%d 相似度%.2f] %s\n&quot;, i+1, h.score, h.text)
			}
			return strings.TrimSpace(sb.String()), nil
		},
	},
}

func toolDefs() []Tool {
	var out []Tool
	for name, t := range tools {
		var td Tool
		td.Type = &quot;function&quot;
		td.Function.Name = name
		td.Function.Description = t.description
		td.Function.Parameters = t.parameters
		out = append(out, td)
	}
	return out
}

// ===========================================
// 会话与 Agent 循环
// ===========================================

func main() {
	fmt.Println(&quot;===== 会话 A：让 Agent 记住一些事实 =====&quot;)
	runSessionA()
	fmt.Println()
	fmt.Println(&quot;===== 会话 B：换一个全新会话（历史不共享），问它记得我吗 =====&quot;)
	runSessionB()
	fmt.Println()
	fmt.Println(&quot;===== 会话 C：查笔记回答问题（轻量 RAG）=====&quot;)
	runSessionC()
}

func runSessionA() {
	runAgent([]Message{{Role: &quot;user&quot;, Content: &quot;请调用两次 remember 工具：第一次 key=用户:名字、value=小明；第二次 key=用户:语言、value=Go。只做这两次调用，不要做其他事。&quot;}})
}

func runSessionB() {
	runAgent([]Message{{Role: &quot;user&quot;, Content: &quot;这是一个全新的会话。请调用 list_memory 一次把长期记忆全部列出来，然后直接根据列出的内容回答我：你记得关于我的什么？列完就不用再调用其他工具了。&quot;}})
}

func runSessionC() {
	runAgent([]Message{{Role: &quot;user&quot;, Content: &quot;问题：Intel Arc 显卡怎么在 Ollama 里启用 GPU 加速？请先 search_notes 检索运维笔记，再严格基于检索到的内容回答。&quot;}})
}

func runAgent(messages []Message) {
	const maxRounds = 6
	for round := 1; round &lt;= maxRounds; round++ {
		resp, err := chat(messages)
		if err != nil {
			fmt.Println(&quot;❌ 请求失败:&quot;, err)
			return
		}
		if len(resp.Choices) == 0 {
			fmt.Println(&quot;❌ 无响应&quot;)
			return
		}
		msg := resp.Choices[0].Message
		finish := resp.Choices[0].FinishReason
		messages = append(messages, msg)
		// 与第 2/3 篇一致：以 tool_calls 非空 + finish_reason==&quot;tool_calls&quot; 为准
		if len(msg.ToolCalls) == 0 || finish != &quot;tool_calls&quot; {
			fmt.Println(&quot;🤖&quot;, msg.Content)
			return
		}
		fmt.Printf(&quot;🔧 第 %d 轮模型请求 %d 个工具\n&quot;, round, len(msg.ToolCalls))
		for _, tc := range msg.ToolCalls {
			t, ok := tools[tc.Function.Name]
			result := &quot;&quot;
			if !ok {
				result = fmt.Sprintf(&quot;未知工具 %q（模型幻觉了工具名）&quot;, tc.Function.Name)
			} else {
				result, err = t.run(json.RawMessage(tc.Function.Arguments))
				if err != nil {
					result = fmt.Sprintf(&quot;工具执行出错：%v。请修正参数后重试，或放弃这一步。&quot;, err)
				}
			}
			fmt.Printf(&quot;   - %s(%s)\n     =&gt; %s\n&quot;, tc.Function.Name, tc.Function.Arguments, result)
			messages = append(messages, Message{Role: &quot;tool&quot;, ToolCallID: tc.ID, Content: result})
		}
	}
}

// ===========================================
// HTTP 调用（chat 与 embed）
// ===========================================

var httpClient = &amp;http.Client{Timeout: 5 * time.Minute} // 请求超时：上游卡住时不至于无限挂起

func chat(messages []Message) (ChatResponse, error) {
	reqBody := ChatRequest{
		Model:       &quot;llama3.1:8b&quot;,
		Messages:    messages,
		Tools:       toolDefs(),
		Stream:      false,
		Temperature: 0, // 演示场景用贪心解码，输出更稳定；聊天场景可调回 0.7
	}
	jsonData, _ := json.Marshal(reqBody)
	resp, err := httpClient.Post(&quot;http://localhost:11434/v1/chat/completions&quot;, &quot;application/json&quot;, bytes.NewBuffer(jsonData))
	if err != nil {
		return ChatResponse{}, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return ChatResponse{}, fmt.Errorf(&quot;upstream %d: %s&quot;, resp.StatusCode, string(b))
	}
	body, _ := io.ReadAll(resp.Body)
	var result ChatResponse
	if err := json.Unmarshal(body, &amp;result); err != nil {
		return ChatResponse{}, fmt.Errorf(&quot;解析失败: %s&quot;, string(body))
	}
	return result, nil
}

func embedTexts(texts []string) ([][]float64, error) {
	payload, _ := json.Marshal(map[string]any{&quot;model&quot;: &quot;nomic-embed-text&quot;, &quot;input&quot;: texts})
	resp, err := httpClient.Post(&quot;http://localhost:11434/api/embed&quot;, &quot;application/json&quot;, bytes.NewBuffer(payload))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf(&quot;embed %d: %s&quot;, resp.StatusCode, string(b))
	}
	var out struct {
		Embeddings [][]float64 `json:&quot;embeddings&quot;`
	}
	if err := json.NewDecoder(resp.Body).Decode(&amp;out); err != nil {
		return nil, err
	}
	return out.Embeddings, nil
}</code></pre>

</details>

<h2 id="5-运行结果-本机实测">5. 运行结果（本机实测）</h2>

<pre><code class="language-bash">go run main.go
</code></pre>

<p>真实输出（Ollama 0.33.3 + llama3.1:8b + nomic-embed-text，<code>temperature=0</code> 保证可复现）：</p>

<pre><code>📚 检测到 nomic-embed-text，启用向量检索
===== 会话 A：让 Agent 记住一些事实 =====
🔧 第 1 轮模型请求 2 个工具
   - remember({&quot;key&quot;:&quot;用户:名字&quot;,&quot;value&quot;:&quot;小明&quot;})
     =&gt; 已记住 用户:名字 = 小明
   - remember({&quot;key&quot;:&quot;用户:语言&quot;,&quot;value&quot;:&quot;Go&quot;})
     =&gt; 已记住 用户:语言 = Go
🤖 这两次调用成功记住了用户的名字和语言。

===== 会话 B：换一个全新会话（历史不共享），问它记得我吗 =====
🔧 第 1 轮模型请求 1 个工具
   - list_memory({})
     =&gt; 用户:名字 = 小明
用户:语言 = Go
🤖 根据列出的内容，我记得关于你的信息如下：

* 名字：小明
* 语言：Go

所以，我知道你是叫小明，且你使用Go语言。

===== 会话 C：查笔记回答问题（轻量 RAG）=====
🔧 第 1 轮模型请求 1 个工具
   - search_notes({&quot;query&quot;:&quot;Intel Arc Ollama GPU 加速&quot;})
     =&gt; （以下是检索到的笔记片段，请严格只依据这些内容回答，不要编造笔记里没有的细节）
[片段1 相似度0.78] Ollama 默认只识别 NVIDIA(CUDA) 与 AMD(ROCm) 显卡；Intel Arc 需要在 systemd override 里设置 OLLAMA_VULKAN=true 并重启服务，才会走 Vulkan 后端。
[片段2 相似度0.69] llama3.1:8b 在 Intel Arc A770 上实测 33/33 层全量 offload，模型占显存约 4.4GB，生成速度约 41 tokens/s。
[片段3 相似度0.65] 验证 Vulkan 是否生效：journalctl -u ollama --no-pager | grep 'inference compute'，看到 library=Vulkan 与 Arc A770 的描述即为成功。
🤖 基于检索到的内容，答案如下：

要在 Ollama 里启用 GPU 加速的 Intel Arc 显卡，请按照以下步骤操作：

1. 在 systemd override 里设置 OLLAMA_VULKAN=true。
2. 重启 Ollama 服务。

这样，Ollama 就会使用 Vulkan 后端，来利用 Intel Arc 显卡的 GPU 加速功能。

另外，为了验证 Vulkan 是否生效，可以使用以下命令：

journalctl -u ollama --no-pager | grep 'inference compute'

如果看到 library=Vulkan 与 Arc A770 的描述，则说明 Vulkan 已经生效，GPU 加速功能已经启用。
</code></pre>

<p>三个会话各演示一件事：</p>

<ol>
<li><strong>A（写入）</strong>：两个 <code>remember</code> 把事实写入 <code>memory.json</code>；</li>
<li><strong>B（跨会话读取）</strong>：全新会话、历史完全不共享，靠 <code>list_memory</code> 枚举后正确回答——这就是&rdquo;记忆&rdquo;与&rdquo;上下文&rdquo;的区别；</li>
<li><strong>C（RAG）</strong>：检索命中&rdquo;设置 <code>OLLAMA_VULKAN=true</code>&ldquo;这条笔记（相似度 0.78），回答严格围绕检索片段，包括验证命令。</li>
</ol>

<blockquote>
<p>想复现干净结果请先删掉旧的 <code>memory.json</code>（<code>rm -f memory.json</code>），否则会带着上次运行的记忆跑。</p>
</blockquote>

<h2 id="6-坑与对照-实测验证">6. 坑与对照（实测验证）</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>

<tbody>
<tr>
<td>让小模型&rdquo;自己抽取事实&rdquo;会写错值</td>
<td>8B 模型对中文抽取不稳（实测把&rdquo;小明&rdquo;写成&rdquo;小明积&rdquo;）</td>
<td>演示场景用显式 key/value 提示；生产让强模型抽取或写入前校验</td>
</tr>

<tr>
<td>新会话里 <code>recall</code> 猜不到键</td>
<td>键值记忆需要&rdquo;知道键名&rdquo;，而键对模型是不可见的</td>
<td>提供 <code>list_memory</code> 枚举工具——枚举是&rdquo;想起来&rdquo;的前提（本篇实测此法稳定）</td>
</tr>

<tr>
<td>检索结果不够准、捞到无关片段</td>
<td>短文本同主题时余弦分数被压在 0.5~0.8 窄带，固定阈值几乎不过滤；前缀的影响也被高估</td>
<td>按语料标定阈值；先用 <code>topK</code> 收敛，再靠&rdquo;只依据片段作答&rdquo;的指令兜底；前缀按模型约定加上即可</td>
</tr>

<tr>
<td>检索对了但模型自己加料</td>
<td>模型不保证&rdquo;照抄片段&rdquo;</td>
<td>工具结果开头显式要求&rdquo;严格只依据这些内容&rdquo;；生产可加引用校验</td>
</tr>

<tr>
<td>输出每次都不一样、难复现</td>
<td>默认采样有随机性</td>
<td>演示/测试用 <code>temperature=0</code>，聊天场景再调回</td>
</tr>

<tr>
<td>检索与对话模型互相换入换出</td>
<td>Ollama 默认每 GPU 驻留模型数有限</td>
<td>显存够可设 <code>OLLAMA_MAX_LOADED_MODELS=2</code>（见第 1 篇第 5.3 节）</td>
</tr>
</tbody>
</table>

<h2 id="7-刻意简化-vs-生产做法">7. 刻意简化 vs 生产做法</h2>

<table>
<thead>
<tr>
<th>刻意简化的地方</th>
<th>生产环境的做法</th>
</tr>
</thead>

<tbody>
<tr>
<td>记忆存本地 JSON 文件</td>
<td>数据库 + 用户维度隔离 + 过期策略</td>
</tr>

<tr>
<td>6 条固定笔记全量向量化</td>
<td>真实文档分块/重叠切分 + 向量库（pgvector/sqlite-vec 等）</td>
</tr>

<tr>
<td>纯向量检索</td>
<td>混合检索（关键词 + 向量）与重排</td>
</tr>

<tr>
<td>阈值写死 0.35</td>
<td>按 embedding 模型与语料调参/评测</td>
</tr>

<tr>
<td>事实记忆是&rdquo;死键值&rdquo;</td>
<td>摘要式长期记忆（把旧对话压成摘要再存）</td>
</tr>
</tbody>
</table>

<h2 id="faq">FAQ</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>没有 embedding 模型能跑吗</td>
<td>能：代码自动降级为关键词检索（启动时探测 <code>/api/tags</code>），只是效果差些</td>
</tr>

<tr>
<td><code>memory.json</code> 越写越大</td>
<td>键值设计 + 定期清理；真实场景换数据库</td>
</tr>

<tr>
<td>笔记很多时全量算余弦太慢</td>
<td>向量库索引；先按标签/目录粗筛再精排</td>
</tr>
</tbody>
</table>

<h2 id="结论">结论</h2>

<ol>
<li><strong>记忆 = 外部状态 + 工具</strong>：Agent 自己记不住，但可以&rdquo;调用工具读写外部存储&rdquo;；</li>
<li><strong>键值记忆补枚举，资料记忆补检索</strong>：两个工具背后是两种不同的信息组织方式；</li>
<li><strong>RAG 的成败在检索细节</strong>：前缀、阈值、片段切分，任何一步糙了答案就飘；</li>
<li><strong>系列收官</strong>：从环境运维 → 最小代码 → 循环 → 服务化 → 记忆，一个不碰 Python 的本地 Agent 已经具备&rdquo;能跑、能调工具、能服务、能记住、能查资料&rdquo;的全部骨架。</li>
</ol>
]]></content:encoded>
      <description><![CDATA[给 Agent 补上两类记忆：跨会话键值事实（remember/recall，落盘 JSON）与对本地笔记的轻量 RAG（embed → 余弦 → topK），并用本机实测讲清阈值与 embedding 前缀这两个常被误传的细节（为什么固定阈值会形同虚设）。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Agent]]></category>
      <category><![CDATA[LLM]]></category>
      <dc:relation><![CDATA[series:go-agent]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[服务与迁移：把 Agent 变成流式 API，可切云端]]></title>
      <link>https://moongate.top/docs/streaming-and-migration</link>
      <guid isPermaLink="true">https://moongate.top/docs/streaming-and-migration</guid>
      <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>本篇做两件事：把第 3 篇的循环<strong>变成常驻 HTTP 服务</strong>（浏览器/客户端通过 SSE 实时接收 token），并让<strong>同一套代码改个 <code>BASE_URL</code> 就能切到 OpenAI 云端</strong>——把第 2 篇的“/v1 近似兼容”铁律落成代码。</p>

<ul>
<li>前置：第 3 篇的多轮循环已跑通</li>
<li>运行要求：Go 1.27+（本篇服务端用到的 <code>&quot;GET /health&quot;</code> 方法路由语法自 Go 1.22 起可用，已满足）</li>
</ul>

<h2 id="1-先看真实报文-两种流式格式的差别-本机抓包">1. 先看真实报文：两种流式格式的差别（本机抓包）</h2>

<p>同样是&rdquo;调用工具&rdquo;请求，Ollama 的 <code>/v1</code>（OpenAI 格式）与原生 <code>/api/chat</code>（ndjson）流式报文<strong>不一样</strong>：</p>

<p><code>/v1/chat/completions</code> + <code>&quot;stream&quot;:true</code>（每行 <code>data: {...}</code>，末尾 <code>data: [DONE]</code>）：</p>

<pre><code class="language-json">data: {&quot;id&quot;:&quot;chatcmpl-876&quot;,...,&quot;choices&quot;:[{&quot;index&quot;:0,&quot;delta&quot;:{&quot;role&quot;:&quot;assistant&quot;,&quot;content&quot;:&quot;&quot;,&quot;tool_calls&quot;:[{&quot;id&quot;:&quot;call_0sfy5hbq&quot;,&quot;index&quot;:0,&quot;type&quot;:&quot;function&quot;,&quot;function&quot;:{&quot;name&quot;:&quot;get_current_time&quot;,&quot;arguments&quot;:&quot;{}&quot;}}]},&quot;finish_reason&quot;:null}]}

data: {&quot;id&quot;:&quot;chatcmpl-876&quot;,...,&quot;choices&quot;:[{&quot;index&quot;:0,&quot;delta&quot;:{},&quot;finish_reason&quot;:&quot;tool_calls&quot;}]}

data: [DONE]
</code></pre>

<p><code>/api/chat</code> + <code>&quot;stream&quot;:true</code>（每行一个 JSON，无 <code>data:</code> 前缀、无 [DONE]）：</p>

<pre><code class="language-json">{&quot;model&quot;:&quot;llama3.1:8b&quot;,&quot;created_at&quot;:&quot;2026-09-07T14:09:28.37Z&quot;,&quot;message&quot;:{&quot;role&quot;:&quot;assistant&quot;,&quot;content&quot;:&quot;&quot;,&quot;tool_calls&quot;:[{&quot;id&quot;:&quot;call_6dem57qn&quot;,&quot;function&quot;:{&quot;index&quot;:0,&quot;name&quot;:&quot;get_current_time&quot;,&quot;arguments&quot;:{}}}]},&quot;done&quot;:false}
{&quot;model&quot;:&quot;llama3.1:8b&quot;,&quot;created_at&quot;:&quot;2026-09-07T14:09:28.40Z&quot;,&quot;message&quot;:{&quot;role&quot;:&quot;assistant&quot;,&quot;content&quot;:&quot;&quot;},&quot;done&quot;:true,&quot;done_reason&quot;:&quot;stop&quot;,&quot;eval_count&quot;:14,...}
</code></pre>

<p>四个关键差异：</p>

<table>
<thead>
<tr>
<th>差异点</th>
<th><code>/v1</code>（OpenAI 格式）</th>
<th><code>/api/chat</code>（原生）</th>
</tr>
</thead>

<tbody>
<tr>
<td>报文包装</td>
<td><code>data: {json}</code>，结尾 <code>data: [DONE]</code></td>
<td>裸 ndjson，无 [DONE]，靠 <code>done:true</code> 判尾</td>
</tr>

<tr>
<td>工具调用结束标记</td>
<td><code>finish_reason:&quot;tool_calls&quot;</code></td>
<td><code>done_reason:&quot;stop&quot;</code>（<strong>即使调用了工具</strong>）</td>
</tr>

<tr>
<td><code>arguments</code> 形态</td>
<td>JSON <strong>字符串</strong>（<code>&quot;{}&quot;</code>）</td>
<td>JSON <strong>对象</strong>（<code>{}</code>）</td>
</tr>

<tr>
<td>兼容性</td>
<td>与 OpenAI 一致，可切云端</td>
<td>Ollama 独有</td>
</tr>
</tbody>
</table>
<p><strong>结论</strong>：想&rdquo;同一套代码可切云端&rdquo;，内部就统一走 <code>/v1</code> 格式；原生 <code>/api/chat</code> 只适合死磕 Ollama 的场景。本篇服务因此只实现一个 OpenAI 兼容客户端。</p>

<h2 id="2-架构-一次对话-一条-sse-事件流">2. 架构：一次对话 = 一条 SSE 事件流</h2>

<pre><code>客户端 ──POST /chat──&gt;  Go 服务 ──/v1/chat/completions stream──&gt; Ollama / 云端
        &lt;── SSE 事件流 ──   (多轮循环：模型输出+工具调用穿插推送)
</code></pre>

<p>事件类型（<code>event:</code> 字段）：</p>

<table>
<thead>
<tr>
<th>事件</th>
<th>含义</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>delta</code></td>
<td>模型输出的一个 token（最终回答逐字出现）</td>
</tr>

<tr>
<td><code>round</code></td>
<td>新的一轮开始、模型请求了 N 个工具</td>
</tr>

<tr>
<td><code>tool</code></td>
<td>工具执行结果（含报错回喂内容）</td>
</tr>

<tr>
<td><code>answer</code></td>
<td>最终回答组装完毕</td>
</tr>

<tr>
<td><code>error</code> / <code>done</code></td>
<td>异常 / 会话结束</td>
</tr>
</tbody>
</table>
<p>浏览器端只需 <code>fetch</code> 后按行读 SSE；curl 测试则如下（见第 5 节真实输出）。</p>

<blockquote>
<p><strong>SSE 最小知识（读懂本节代码只需要这三条）</strong>：① 一条事件 = <code>event: 类型</code> + 若干 <code>data:</code> 行 + 一个<strong>空行</strong>收尾，客户端按行读、遇空行结束当前事件（SSE 即 Server-Sent Events，浏览器原生的服务端推送）；② 服务器每发一个事件都要 <code>Flush()</code>，否则第一批 token 会被缓冲区攒着，浏览器要等很久才看到；③ 连接长开，断开由客户端或超时决定——本篇没做取消与心跳，生产差异见第 8 节。</p>
</blockquote>

<h2 id="3-两个最容易出错的流式细节-代码已做兼容处理">3. 两个最容易出错的流式细节（代码已做兼容处理）</h2>

<p><strong>① <code>delta.tool_calls</code> 的 <code>arguments</code> 可能是分片的。</strong> OpenAI 云端流式时会把一个工具调用的参数 JSON <strong>拆成多段</strong>下发，客户端必须按 <code>index</code> 把多段拼起来；Ollama 本地实测是整段下发（见第 1 节抓包），但代码按&rdquo;可能拆片&rdquo;来写，两边都能跑：</p>

<pre><code class="language-go">if tc.Function.Arguments != &quot;&quot; {
    p.arguments.WriteString(tc.Function.Arguments) // 分片必须拼接
}
</code></pre>

<p><strong>② <code>content</code> 可能是空串、<code>finish_reason</code> 在最后一行才出现。</strong> 判断&rdquo;这轮要不要执行工具&rdquo;，永远以<strong>累积完一整轮后</strong>的 <code>finish_reason == &quot;tool_calls&quot;</code> / <code>tool_calls</code> 非空为准，不要在收到第一个 chunk 时就下结论。</p>

<p>另：代码里的 <code>sc.Buffer(make([]byte, 1024), 1&lt;&lt;20)</code> 把单行上限从 <code>bufio.Scanner</code> 默认的 64KB 提到 1MB——SSE 的一行（整段 <code>data:</code>）可能很长，不调大会直接报 <code>token too long</code> 并中断流。</p>

<h2 id="4-完整代码">4. 完整代码</h2>

<details>
<summary>main.go 全文（点击展开）</summary>

<pre><code class="language-go">// 第 4 篇演示：把 Agent 循环变成常驻 HTTP 服务（SSE 流式 + 可切云端）
//
// 设计要点：
//  1. 只实现一个 OpenAI 兼容客户端（/v1/chat/completions），通过
//     BASE_URL 指向本地 Ollama 或任意 OpenAI 兼容云服务——同一套代码可切换；
//  2. 用流式（stream:true）做多轮工具循环：每一轮的模型输出实时
//     以 SSE 转发给浏览器/客户端，工具调用与结果也以事件形式发出；
//  3. delta 解析做到&quot;防呆&quot;：content 可能为 &quot;&quot; 或 null、tool_calls 的
//     arguments 可能被拆成多个分片（OpenAI 会拆，Ollama 一次性给全），
//     按 index 累积拼接，两种服务端都能正确处理。
//
// 运行：
//
//	OLLAMA_BASE=&quot;http://localhost:11434/v1&quot; OLLAMA_MODEL=llama3.1:8b go run main.go
//	默认监听 :8899。
package main

import (
	&quot;bufio&quot;
	&quot;bytes&quot;
	&quot;encoding/json&quot;
	&quot;errors&quot;
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;net/http&quot;
	&quot;os&quot;
	&quot;strings&quot;
	&quot;time&quot;
)

// ===========================================
// 消息模型（与 /v1/chat/completions 对应）
// ===========================================

type Message struct {
	Role       string     `json:&quot;role&quot;`
	Content    string     `json:&quot;content,omitempty&quot;`
	ToolCalls  []ToolCall `json:&quot;tool_calls,omitempty&quot;`
	ToolCallID string     `json:&quot;tool_call_id,omitempty&quot;`
}

type ToolCall struct {
	ID       string `json:&quot;id&quot;`
	Type     string `json:&quot;type&quot;`
	Function struct {
		Name      string `json:&quot;name&quot;`
		Arguments string `json:&quot;arguments&quot;`
	} `json:&quot;function&quot;`
}

type Tool struct {
	Type     string `json:&quot;type&quot;`
	Function struct {
		Name        string         `json:&quot;name&quot;`
		Description string         `json:&quot;description&quot;`
		Parameters  map[string]any `json:&quot;parameters&quot;`
	} `json:&quot;function&quot;`
}

// ===========================================
// 流式 chunk 的解析结构
// ===========================================

type streamChunk struct {
	Choices []struct {
		Delta struct {
			Role      string `json:&quot;role&quot;`
			Content   string `json:&quot;content&quot;`
			ToolCalls []struct {
				Index    int    `json:&quot;index&quot;`
				ID       string `json:&quot;id&quot;`
				Type     string `json:&quot;type&quot;`
				Function struct {
					Name      string `json:&quot;name&quot;`
					Arguments string `json:&quot;arguments&quot;`
				} `json:&quot;function&quot;`
			} `json:&quot;tool_calls&quot;`
		} `json:&quot;delta&quot;`
		FinishReason string `json:&quot;finish_reason&quot;`
	} `json:&quot;choices&quot;`
}

// pendingCall 累积一个&quot;还没发完&quot;的工具调用（按 index 对齐）。
type pendingCall struct {
	index     int
	id        string
	name      string
	arguments strings.Builder
}

// ===========================================
// 工具注册表（与第 3 篇相同，省注释）
// ===========================================

type tool struct {
	description string
	parameters  map[string]any
	run         func(args json.RawMessage) (string, error)
}

var tools = map[string]*tool{
	&quot;get_current_time&quot;: {
		description: &quot;获取当前的日期和时间&quot;,
		parameters:  map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{}},
		run: func(args json.RawMessage) (string, error) {
			return time.Now().Format(&quot;2006-01-02 15:04:05&quot;), nil
		},
	},
	&quot;add&quot;: {
		description: &quot;计算两个整数 a 与 b 的和&quot;,
		parameters: map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{
			&quot;a&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
			&quot;b&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
		}, &quot;required&quot;: []string{&quot;a&quot;, &quot;b&quot;}},
		run: intTool(func(a, b int) (string, error) { return fmt.Sprintf(&quot;%d&quot;, a+b), nil }),
	},
	&quot;multiply&quot;: {
		description: &quot;计算两个整数 a 与 b 的乘积&quot;,
		parameters: map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{
			&quot;a&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
			&quot;b&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
		}, &quot;required&quot;: []string{&quot;a&quot;, &quot;b&quot;}},
		run: intTool(func(a, b int) (string, error) { return fmt.Sprintf(&quot;%d&quot;, a*b), nil }),
	},
	&quot;divide&quot;: {
		description: &quot;计算两个整数 a 除以 b 的商（整除）&quot;,
		parameters: map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{
			&quot;a&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
			&quot;b&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
		}, &quot;required&quot;: []string{&quot;a&quot;, &quot;b&quot;}},
		run: intTool(func(a, b int) (string, error) {
			if b == 0 {
				return &quot;&quot;, errors.New(&quot;除数不能为 0&quot;)
			}
			return fmt.Sprintf(&quot;%d&quot;, a/b), nil
		}),
	},
}

func intTool(fn func(a, b int) (string, error)) func(json.RawMessage) (string, error) {
	return func(args json.RawMessage) (string, error) {
		var p struct {
			A int `json:&quot;a&quot;`
			B int `json:&quot;b&quot;`
		}
		if err := json.Unmarshal(args, &amp;p); err != nil {
			return &quot;&quot;, fmt.Errorf(&quot;参数解析失败（应为 {\&quot;a\&quot;:整数,\&quot;b\&quot;:整数}）：%v&quot;, err)
		}
		return fn(p.A, p.B)
	}
}

func toolDefs() []Tool {
	var out []Tool
	for name, t := range tools {
		var td Tool
		td.Type = &quot;function&quot;
		td.Function.Name = name
		td.Function.Description = t.description
		td.Function.Parameters = t.parameters
		out = append(out, td)
	}
	return out
}

// ===========================================
// OpenAI 兼容客户端（BASE_URL 可切本地/云端）
// ===========================================

type client struct {
	baseURL string // 例如 http://localhost:11434/v1 或 https://api.openai.com/v1
	apiKey  string
	model   string
	http    *http.Client
}

func newClient() *client {
	base := os.Getenv(&quot;OLLAMA_BASE&quot;)
	if base == &quot;&quot; {
		base = &quot;http://localhost:11434/v1&quot;
	}
	key := os.Getenv(&quot;OLLAMA_API_KEY&quot;)
	if key == &quot;&quot; {
		key = &quot;ollama&quot; // Ollama 忽略 key，OpenAI 需要填真 key
	}
	model := os.Getenv(&quot;OLLAMA_MODEL&quot;)
	if model == &quot;&quot; {
		model = &quot;llama3.1:8b&quot;
	}
	return &amp;client{baseURL: base, apiKey: key, model: model,
		http: &amp;http.Client{Timeout: 5 * time.Minute}}
}

// streamChat 发一次流式请求，把模型输出累积成一条 assistant 消息。
// callback 用于把每个 token 实时推给下游（SSE）。
func (c *client) streamChat(messages []Message, onDelta func(string)) (Message, string, error) {
	body, _ := json.Marshal(map[string]any{
		&quot;model&quot;:    c.model,
		&quot;messages&quot;: messages,
		&quot;tools&quot;:    toolDefs(),
		&quot;stream&quot;:   true,
	})
	req, err := http.NewRequest(http.MethodPost, c.baseURL+&quot;/chat/completions&quot;, bytes.NewReader(body))
	if err != nil {
		return Message{}, &quot;&quot;, err
	}
	req.Header.Set(&quot;Content-Type&quot;, &quot;application/json&quot;)
	req.Header.Set(&quot;Authorization&quot;, &quot;Bearer &quot;+c.apiKey)

	resp, err := c.http.Do(req)
	if err != nil {
		return Message{}, &quot;&quot;, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return Message{}, &quot;&quot;, fmt.Errorf(&quot;upstream %d: %s&quot;, resp.StatusCode, string(b))
	}

	var (
		acc       strings.Builder // 累积本轮 content（最终回答）
		calls     []*pendingCall  // 累积本轮 tool_calls（按 index）
		finish    string
		callByIdx = map[int]*pendingCall{}
	)

	sc := bufio.NewScanner(resp.Body)
	sc.Buffer(make([]byte, 1024), 1&lt;&lt;20)
	for sc.Scan() {
		line := strings.TrimSpace(sc.Text())
		if !strings.HasPrefix(line, &quot;data:&quot;) {
			continue
		}
		data := strings.TrimSpace(strings.TrimPrefix(line, &quot;data:&quot;))
		if data == &quot;[DONE]&quot; {
			break
		}
		var ch streamChunk
		if err := json.Unmarshal([]byte(data), &amp;ch); err != nil {
			continue // 忽略无法解析的行（兼容性防呆）
		}
		for _, choice := range ch.Choices {
			if choice.FinishReason != &quot;&quot; {
				finish = choice.FinishReason
			}
			d := choice.Delta
			if d.Content != &quot;&quot; {
				acc.WriteString(d.Content)
				onDelta(d.Content)
			}
			for _, tc := range d.ToolCalls {
				p := callByIdx[tc.Index]
				if p == nil {
					p = &amp;pendingCall{index: tc.Index}
					callByIdx[tc.Index] = p
					calls = append(calls, p)
				}
				if tc.ID != &quot;&quot; {
					p.id = tc.ID
				}
				if tc.Function.Name != &quot;&quot; {
					p.name = tc.Function.Name // 分片场景下若拆名则追加，此处取简版
				}
				p.arguments.WriteString(tc.Function.Arguments) // arguments 可能被拆成多片，必须拼接
			}
		}
	}

	msg := Message{Role: &quot;assistant&quot;, Content: acc.String()}
	for _, p := range calls {
		var tc ToolCall
		tc.ID = p.id
		tc.Type = &quot;function&quot;
		tc.Function.Name = p.name
		tc.Function.Arguments = p.arguments.String()
		msg.ToolCalls = append(msg.ToolCalls, tc)
	}
	return msg, finish, nil
}

// ===========================================
// HTTP 服务
// ===========================================

type server struct {
	client *client
}

func main() {
	s := &amp;server{client: newClient()}

	mux := http.NewServeMux()
	mux.HandleFunc(&quot;GET /health&quot;, func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]string{&quot;status&quot;: &quot;ok&quot;, &quot;model&quot;: s.client.model})
	})
	mux.HandleFunc(&quot;POST /chat&quot;, s.handleChat)

	port := os.Getenv(&quot;PORT&quot;)
	if port == &quot;&quot; {
		port = &quot;8899&quot;
	}
	fmt.Printf(&quot;stream-server 已启动: http://localhost:%s  (base=%s model=%s)\n&quot;,
		port, s.client.baseURL, s.client.model)
	if err := http.ListenAndServe(&quot;:&quot;+port, mux); err != nil {
		fmt.Println(&quot;启动失败:&quot;, err)
	}
}

// handleChat：多轮 Agent 循环 + SSE 实时推送。
func (s *server) handleChat(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Messages []Message `json:&quot;messages&quot;`
		Stream   bool      `json:&quot;stream&quot;`
	}
	if err := json.NewDecoder(r.Body).Decode(&amp;req); err != nil {
		http.Error(w, &quot;bad json: &quot;+err.Error(), http.StatusBadRequest)
		return
	}

	if req.Stream {
		s.runAgentSSE(w, req.Messages)
		return
	}
	// 非流式：跑同一套逻辑，把事件收进内存，最后返回 JSON
	events := &amp;eventSink{}
	s.runAgent(events, req.Messages)
	answer, _ := events.lastAnswer()
	json.NewEncoder(w).Encode(map[string]any{&quot;answer&quot;: answer, &quot;events&quot;: events.list})
}

type event struct {
	Type string `json:&quot;type&quot;`
	Data string `json:&quot;data&quot;`
}

// eventSink 收集事件（非流式路径用）。
type eventSink struct{ list []event }

func (e *eventSink) emit(t, d string) { e.list = append(e.list, event{t, d}) }
func (e *eventSink) lastAnswer() (string, bool) {
	for i := len(e.list) - 1; i &gt;= 0; i-- {
		if e.list[i].Type == &quot;answer&quot; {
			return e.list[i].Data, true
		}
	}
	return &quot;&quot;, false
}

// sseWriter 直接把事件写给浏览器（流式路径用）。
type sseWriter struct {
	w http.ResponseWriter
	f http.Flusher
}

// emit 写一个 SSE 事件。data 可能含换行（模型的段落/空行 token），必须按行
// 拆成多条 data: 行：SSE 规范中连续 data 行会以 \n 重新拼接，这样既保持帧
// 合法，又能逐字保留换行（若把 \n 直接写进单行 data，空行会提前终止事件、
// 后续裸行会被客户端丢弃）。
func (s *sseWriter) emit(t, d string) {
	fmt.Fprintf(s.w, &quot;event: %s\n&quot;, t)
	for _, line := range strings.Split(d, &quot;\n&quot;) {
		fmt.Fprintf(s.w, &quot;data: %s\n&quot;, line)
	}
	fmt.Fprint(s.w, &quot;\n&quot;)
	s.f.Flush()
}

// emitter 是两种输出目标的共同接口。
type emitter interface{ emit(typ, data string) }

// runAgent 是核心多轮循环：模型输出逐 token 推送；工具调用执行后推送结果。
func (s *server) runAgent(em emitter, history []Message) {
	const maxRounds = 8
	for round := 1; round &lt;= maxRounds; round++ {
		msg, finish, err := s.client.streamChat(history, func(token string) {
			em.emit(&quot;delta&quot;, token) // 每个 token 实时推送
		})
		if err != nil {
			em.emit(&quot;error&quot;, err.Error())
			return
		}
		history = append(history, msg)

		if len(msg.ToolCalls) == 0 || finish != &quot;tool_calls&quot; {
			em.emit(&quot;answer&quot;, msg.Content)
			return
		}
		em.emit(&quot;round&quot;, fmt.Sprintf(&quot;第 %d 轮：模型请求 %d 个工具&quot;, round, len(msg.ToolCalls)))
		for _, tc := range msg.ToolCalls {
			result, err := runTool(tc)
			if err != nil {
				result = fmt.Sprintf(&quot;工具执行出错：%v。请修正参数后重试，或放弃这一步。&quot;, err)
			}
			em.emit(&quot;tool&quot;, fmt.Sprintf(&quot;%s(%s) -&gt; %s&quot;, tc.Function.Name, tc.Function.Arguments, result))
			history = append(history, Message{Role: &quot;tool&quot;, ToolCallID: tc.ID, Content: result})
		}
	}
	em.emit(&quot;error&quot;, &quot;达到最大轮数&quot;)
}

func (s *server) runAgentSSE(w http.ResponseWriter, history []Message) {
	w.Header().Set(&quot;Content-Type&quot;, &quot;text/event-stream&quot;)
	w.Header().Set(&quot;Cache-Control&quot;, &quot;no-cache&quot;)
	w.Header().Set(&quot;Connection&quot;, &quot;keep-alive&quot;)
	f, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, &quot;streaming unsupported&quot;, http.StatusInternalServerError)
		return
	}
	sw := &amp;sseWriter{w: w, f: f}
	s.runAgent(sw, history)
	sw.emit(&quot;done&quot;, &quot;[DONE]&quot;)
}

func runTool(tc ToolCall) (string, error) {
	t, ok := tools[tc.Function.Name]
	if !ok {
		return &quot;&quot;, fmt.Errorf(&quot;未知工具 %q（模型幻觉了工具名）&quot;, tc.Function.Name)
	}
	return t.run(json.RawMessage(tc.Function.Arguments))
}</code></pre>

</details>

<h2 id="5-运行结果-ollama-0-33-3-llama3-1-8b-2026-09-实测">5. 运行结果（Ollama 0.33.3 + llama3.1:8b，2026-09 实测）</h2>

<pre><code class="language-bash"># 启动（BASE_URL 指向本地 Ollama；切云端只需改环境变量，见第 6 节）
PORT=8899 go run main.go
</code></pre>

<p><code>GET /health</code>：</p>

<pre><code>{&quot;model&quot;:&quot;llama3.1:8b&quot;,&quot;status&quot;:&quot;ok&quot;}
</code></pre>

<p>一次完整 SSE 对话（浏览器视角收到的原始事件流）：</p>

<pre><code class="language-bash">curl -sN -X POST http://localhost:8899/chat -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;stream&quot;:true,&quot;messages&quot;:[{&quot;role&quot;:&quot;user&quot;,&quot;content&quot;:&quot;现在几点了？顺便帮我算 7 乘以 8。&quot;}]}'
</code></pre>

<pre><code>event: round
data: 第 1 轮：模型请求 2 个工具

event: tool
data: get_current_time({}) -&gt; 2026-09-07 22:10:16

event: tool
data: multiply({&quot;b&quot;:8,&quot;a&quot;:7}) -&gt; 56

event: delta
data: 现在
event: delta
data: 是
event: delta
data: 22
event: delta
data: :
event: delta
data: 10
…
（其余 9 个 delta 事件从略：单 token 逐字到达，直到整句拼完）

event: answer
data: 现在是 22:10。7 乘以 8 等于 56。

event: done
data: [DONE]
</code></pre>

<p>要点：工具调用回合（<code>round</code>/<code>tool</code>）<strong>先于</strong>最终回答出现，最终回答的 token 逐字流式到达（<code>delta</code>），浏览器可以边收边渲染。非流式路径（<code>&quot;stream&quot;:false</code>）返回同一份结果的 JSON，方便调试与测试。</p>

<h2 id="6-切云端-只改环境变量-不改代码">6. 切云端：只改环境变量，不改代码</h2>

<p>服务内部只认 OpenAI 兼容协议，所以&rdquo;本地 Ollama ↔ 云端&rdquo;只是换三个环境变量：</p>

<pre><code class="language-bash"># 本地
OLLAMA_BASE=&quot;http://localhost:11434/v1&quot; OLLAMA_API_KEY=ollama OLLAMA_MODEL=llama3.1:8b

# 云端（已实测：小米 MiMo API，OpenAI 兼容协议）
OLLAMA_BASE=&quot;https://api.xiaomimimo.com/v1&quot; OLLAMA_API_KEY=sk-xxx OLLAMA_MODEL=mimo-v2.5-pro
</code></pre>

<p>第 2 篇的兼容铁律在这份代码里已经落实：</p>

<ul>
<li>无工具时<strong>省略 <code>tools</code> 字段</strong>（<code>toolDefs()</code> 恒有工具，生产可改条件化）——避免踩空 <code>tools: []</code> 的差异；</li>
<li>判工具调用看 <code>finish_reason == &quot;tool_calls&quot;</code>，不只看 content；</li>
<li><code>delta.content</code> 判空同时兼容 <code>&quot;&quot;</code> 与缺失。</li>
</ul>

<h3 id="云端实测-mimo-v2-5-pro-2026-09-08">云端实测（MiMo-v2.5-pro，2026-09-08）</h3>

<p>同一份代码、同一个 curl 请求，只改环境变量指向 MiMo 云端：</p>

<pre><code>event: round
data: 第 1 轮：模型请求 2 个工具

event: tool
data: get_current_time({}) -&gt; 2026-09-08 16:48:21

event: tool
data: multiply({&quot;a&quot;: 7, &quot;b&quot;: 8}) -&gt; 56

…（delta 事件从略，逐 token 到达；这里注意 `arguments` 带空格）

event: answer
data: 现在是 **2026年9月8日 16:48:21**。另外，**7 × 8 = 56**。还有其他需要帮忙的吗？😊

event: done
data: [DONE]
</code></pre>

<p>与本地 Ollama 对比：事件流结构完全一致（<code>round → tool → delta → answer → done</code>），代码零改动。唯一可注意的细微差异：MiMo 的 <code>arguments</code> 以带空格的 JSON 对象下发（<code>&quot;a&quot;: 7</code>），Ollama 以紧凑字符串下发（<code>&quot;{}&quot;</code>），两种都被 <code>json.Unmarshal</code> 正确处理——代码第 3 节的防呆设计恰好覆盖了这类差异。</p>

<h2 id="7-坑与对照">7. 坑与对照</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>

<tbody>
<tr>
<td>SSE 断断续续/客户端收不全</td>
<td>没理解 <code>event:</code>/<code>data:</code>/空行分隔</td>
<td>按行读、遇到空行结束当前事件（见第 5 节原始流）</td>
</tr>

<tr>
<td>工具调用参数丢失一半</td>
<td>把分片 arguments 当成了完整 JSON</td>
<td>按 <code>index</code> 累积拼接后再整体解析</td>
</tr>

<tr>
<td>模型已调工具却当普通回答处理</td>
<td>只看了第一行 chunk 就返回</td>
<td>收完整轮再判 <code>finish_reason</code></td>
</tr>

<tr>
<td>本地正常、云端行为不同</td>
<td>/v1 是近似兼容，云端字段更严</td>
<td>已实测 MiMo 云端（§6），代码零改动；换其他服务前仍建议先跑一遍</td>
</tr>

<tr>
<td>长任务超时 / curl 提前断开</td>
<td>上游请求没有超时预算；或代理缓冲没关</td>
<td><code>http.Client.Timeout</code> 按需求调大 + 服务端 <code>context</code> 取消；SSE 需关闭代理缓冲</td>
</tr>
</tbody>
</table>

<h2 id="8-刻意简化-vs-生产做法">8. 刻意简化 vs 生产做法</h2>

<table>
<thead>
<tr>
<th>刻意简化的地方</th>
<th>生产环境的做法</th>
</tr>
</thead>

<tbody>
<tr>
<td>服务无鉴权、无限流</td>
<td>API key / 中间件 / 每用户配额</td>
</tr>

<tr>
<td>对话历史只存在单次请求内</td>
<td>会话隔离思路见第 5 篇；生产级会话存储/多租户不在本系列范围</td>
</tr>

<tr>
<td>SSE 一个连接跑完整轮</td>
<td>任务队列 + 进度事件重连</td>
</tr>

<tr>
<td>工具固定 4 个写死</td>
<td>注册式插件 / 配置加载</td>
</tr>

<tr>
<td>已实测云端迁移</td>
<td>换其他 OpenAI 兼容服务前仍建议跑一遍兼容清单</td>
</tr>
</tbody>
</table>

<h2 id="faq">FAQ</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>浏览器收不到流</td>
<td>服务端必须 <code>Flush()</code>；确认响应头 <code>text/event-stream</code></td>
</tr>

<tr>
<td>想比较 /api/chat 原生流</td>
<td>用第 1 节抓包命令自行对比；服务默认统一走 /v1</td>
</tr>

<tr>
<td>切云端报 401</td>
<td>检查 <code>OLLAMA_API_KEY</code>；Ollama 本地会忽略 key</td>
</tr>
</tbody>
</table>

<h2 id="结论">结论</h2>

<ol>
<li><strong>服务化只多了&rdquo;外壳&rdquo;</strong>：核心仍是第 3 篇的循环，加一层 HTTP + SSE 事件转发即可；</li>
<li><strong>流式细节决定成败</strong>：分片 arguments 拼接、整轮判 <code>finish_reason</code>、<code>Flush()</code>，三件事缺一不可；</li>
<li><strong>可迁移性来自协议收敛</strong>：只依赖 OpenAI 兼容子集，本地/云端切换 = 改环境变量；</li>
<li>服务目前仍无记忆——<strong>会话隔离、跨会话长期记忆与轻量 RAG</strong> 是第 5 篇的主题（进程内演示形态）；生产级会话存储/多租户不在本系列范围。</li>
</ol>

<p>下一篇预告：<strong>《记忆：多会话与轻量 RAG》</strong>。</p>
]]></content:encoded>
      <description><![CDATA[把 Agent 循环变成常驻 HTTP 服务：SSE 逐 token 推送与多轮事件流，同一套代码只改环境变量即可从本地 Ollama 切到 OpenAI 兼容云端。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Agent]]></category>
      <category><![CDATA[LLM]]></category>
      <dc:relation><![CDATA[series:go-agent]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[循环：让 Agent 自己决定调几次工具]]></title>
      <link>https://moongate.top/docs/agent-loop</link>
      <guid isPermaLink="true">https://moongate.top/docs/agent-loop</guid>
      <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>第 2 篇停在“单工具、单轮”：一次问答最多调一次工具就收尾。本篇把“一轮”改成“循环”——模型可以连续请求多轮工具（多工具注册、并行调用、错误回喂、历史裁剪），直到它给出最终回答。</p>

<ul>
<li>前置：已理解第 2 篇机制（<code>role=tool</code> + <code>tool_call_id</code> 对齐、<code>tool_calls</code> 非空判定、<code>/v1</code> 兼容铁律）</li>
</ul>

<h2 id="1-从一轮到多轮-agentic-循环长什么样">1. 从一轮到多轮：Agentic 循环长什么样</h2>

<p>第 2 篇的代码是&rdquo;问一次 → 最多调一次工具 → 收尾&rdquo;，本质是<strong>单轮</strong>。真实 Agent 的形态是<strong>循环</strong>：</p>

<pre><code>for {
    发给模型（历史 + 工具清单）
    若响应里没有 tool_calls：输出回答，结束
    否则：执行所有 tool_calls，结果回传，继续下一轮
}
</code></pre>

<p>和第 2 篇相比，本篇新增四件事：</p>

<table>
<thead>
<tr>
<th>新增能力</th>
<th>为什么需要</th>
</tr>
</thead>

<tbody>
<tr>
<td>多个工具注册</td>
<td>一个 Agent 通常有多个工具，且要按 JSON Schema 声明参数</td>
</tr>

<tr>
<td>一次响应多个 <code>tool_calls</code></td>
<td>模型可能一轮里并行请求多个工具（本次实测一轮 3 个）</td>
</tr>

<tr>
<td>错误回喂</td>
<td>工具执行失败要让模型知道，由它修正参数或放弃</td>
</tr>

<tr>
<td>历史裁剪</td>
<td>每轮都要把整段历史重发，上下文随轮次线性增长，必须设上限</td>
</tr>
</tbody>
</table>

<h2 id="2-工具注册表-描述-参数-schema-与实现放在一起">2. 工具注册表：描述、参数 Schema 与实现放在一起</h2>

<p>第 2 篇的 <code>toolMap</code> 只存了&rdquo;名字 → 函数&rdquo;，参数 Schema 散在 main 里。本篇把三者收进一张表：</p>

<pre><code class="language-go">var tools = map[string]*tool{
    &quot;divide&quot;: {
        description: &quot;计算两个整数 a 除以 b 的商（整除）&quot;,
        parameters: map[string]any{
            &quot;type&quot;: &quot;object&quot;,
            &quot;properties&quot;: map[string]any{
                &quot;a&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
                &quot;b&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
            },
            &quot;required&quot;: []string{&quot;a&quot;, &quot;b&quot;},
        },
        run: func(args json.RawMessage) (string, error) {
            // 解析参数、执行业务、返回字符串结果
        },
    },
}
</code></pre>

<ul>
<li><code>description</code> 是模型&rdquo;读懂&rdquo;工具的关键：写清参数含义，模型才填得对；</li>
<li><code>parameters</code> 走 JSON Schema（<code>type</code>/<code>properties</code>/<code>required</code>），Ollama 与 OpenAI 都按这个格式下发；</li>
<li><strong>模型传来的 <code>arguments</code> 是 JSON 字符串</strong>，执行前必须自己 <code>json.Unmarshal</code>（第 2 篇讲过），本篇的 <code>add</code>/<code>multiply</code>/<code>divide</code> 都演示了解析失败时报错。</li>
</ul>

<h2 id="3-错误回喂-工具报错时-把错误当普通工具结果返回">3. 错误回喂：工具报错时，把错误当普通工具结果返回</h2>

<p>工具执行失败不要直接中断整个循环，而是把错误<strong>包装成 <code>role=&quot;tool&quot;</code> 消息内容</strong>回传：</p>

<pre><code class="language-go">result, err := runTool(tc)
if err != nil {
    result = fmt.Sprintf(&quot;工具执行出错：%v。请修正参数后重试，或放弃这一步。&quot;, err)
}
</code></pre>

<p>模型会读到这条&rdquo;结果&rdquo;并自行决策：修正参数重试、换工具、或向用户说明放弃。本文实测里，模型先故意调 <code>divide(10, 0)</code> 触发错误，收到&rdquo;除数不能为 0&rdquo;后<strong>选择跳过这一步</strong>并继续完成其余任务——放弃也是一种合法策略，见第 7 节讨论。</p>

<p>另：助手返回的 <code>assistant</code> 消息（含 <code>tool_calls</code>）必须<strong>先 append 进 <code>messages</code></strong>，再 append <code>role=&quot;tool&quot;</code> 的结果；顺序反了，工具结果就找不到对应的调用，上游会直接校验失败——这也是第 4 节 <code>trimHistory</code> 必须整组删除的原因。</p>

<h2 id="4-历史增长-为什么必须有上限-怎么简单处理">4. 历史增长：为什么必须有上限，怎么简单处理</h2>

<p>循环的代价是：每轮都要把<strong>从第一条 user 到现在的全部消息</strong>重发给模型（OpenAI/Ollama 都是无状态接口，历史靠客户端累积）。多轮之后：</p>

<ul>
<li>prompt tokens 线性增长 → 每轮更慢、更贵（本地是更慢）；</li>
<li>超过模型上下文窗口会直接报错或静默截断。</li>
</ul>

<p>本篇实现了一个极简保险丝：当某轮 <code>prompt_tokens</code> 超过阈值且历史足够长时，丢掉最早的一轮对话（<code>trimHistory</code>）。生产上更常见的做法是&rdquo;超长则把旧对话压成摘要再继续&rdquo;——那属于摘要式长期记忆，本系列不展开（第 5 篇讲的是&rdquo;键值事实记忆 + 资料检索（RAG）&rdquo;两类）。</p>

<blockquote>
<p>注意：<code>trimHistory</code> 的裁剪点落在两轮 user 消息之间——整组删除最老那轮（user + 其 assistant + 全部 <code>role=tool</code> 结果），避免删出「无 assistant 对应的孤儿 tool 消息」；本示例是单个问题一路调工具跑到底，不会触发它，把它接入真实多轮对话（每轮追加新的 user 消息）后才会生效。</p>
</blockquote>

<h2 id="5-完整代码">5. 完整代码</h2>

<details>
<summary>main.go 全文（点击展开）</summary>

<pre><code class="language-go">// 第 3 篇演示：让 Agent 自己决定调几次工具（多轮循环版）
//
// 与第 2 篇最小案例的差异：
//  1. 多个工具（时间 / 加法 / 乘法 / 除法），按 JSON Schema 声明参数；
//  2. while 循环：只要模型还在请求工具就继续，直到它输出普通回答；
//  3. 一次响应可能带多个 tool_calls（并行调用），全部执行后一起回传；
//  4. 工具执行失败时把错误信息作为 role=&quot;tool&quot; 内容回喂，让模型自行修正或放弃；
//  5. 简单的历史裁剪（防止上下文无限增长）。
package main

import (
	&quot;bytes&quot;
	&quot;encoding/json&quot;
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;net/http&quot;
	&quot;os&quot;
	&quot;strings&quot;
	&quot;time&quot;
)

// ===========================================
// 与 Ollama /v1/chat/completions 对应的结构
// ===========================================

type Message struct {
	Role       string     `json:&quot;role&quot;`
	Content    string     `json:&quot;content,omitempty&quot;`
	ToolCalls  []ToolCall `json:&quot;tool_calls,omitempty&quot;`
	ToolCallID string     `json:&quot;tool_call_id,omitempty&quot;`
}

type ToolCall struct {
	ID       string `json:&quot;id&quot;`
	Function struct {
		Name      string `json:&quot;name&quot;`
		Arguments string `json:&quot;arguments&quot;` // 模型给的是 JSON 字符串，执行前要再解析
	} `json:&quot;function&quot;`
}

type Tool struct {
	Type     string `json:&quot;type&quot;`
	Function struct {
		Name        string         `json:&quot;name&quot;`
		Description string         `json:&quot;description&quot;`
		Parameters  map[string]any `json:&quot;parameters&quot;`
	} `json:&quot;function&quot;`
}

type ChatRequest struct {
	Model    string    `json:&quot;model&quot;`
	Messages []Message `json:&quot;messages&quot;`
	Tools    []Tool    `json:&quot;tools,omitempty&quot;` // 空则不传，遵守第 2 篇的兼容铁律
	Stream   bool      `json:&quot;stream&quot;`
}

type Choice struct {
	Message      Message `json:&quot;message&quot;`
	FinishReason string  `json:&quot;finish_reason&quot;`
}

type ChatResponse struct {
	Choices []Choice `json:&quot;choices&quot;`
	Usage   struct {
		PromptTokens     int `json:&quot;prompt_tokens&quot;`
		CompletionTokens int `json:&quot;completion_tokens&quot;`
	} `json:&quot;usage&quot;`
}

// ===========================================
// 工具注册表：名字 -&gt; 描述 + 参数 Schema + 实现
// ===========================================

// tool 描述一个可被模型调用的工具。
// Run 收到的 args 是模型输出的 arguments JSON 字符串，需要自己解析。
type tool struct {
	description string
	parameters  map[string]any
	run         func(args json.RawMessage) (string, error)
}

// binaryOp 提取了三个算术工具的公共模式：解析 {a,b} 整数参数 → 执行运算 → 返回结果。
// 每个工具只需传入一行运算函数（如 func(a,b int)(int,error){ return a+b, nil }）。
func binaryOp(name, desc string, fn func(int, int) (int, error)) *tool {
	return &amp;tool{
		description: desc,
		parameters: map[string]any{
			&quot;type&quot;: &quot;object&quot;,
			&quot;properties&quot;: map[string]any{
				&quot;a&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
				&quot;b&quot;: map[string]any{&quot;type&quot;: &quot;integer&quot;},
			},
			&quot;required&quot;: []string{&quot;a&quot;, &quot;b&quot;},
		},
		run: func(args json.RawMessage) (string, error) {
			var p struct{ A, B int }
			if err := json.Unmarshal(args, &amp;p); err != nil {
				return &quot;&quot;, fmt.Errorf(&quot;参数解析失败（应为 {\&quot;a\&quot;:整数,\&quot;b\&quot;:整数}）：%v&quot;, err)
			}
			v, err := fn(p.A, p.B)
			if err != nil {
				return &quot;&quot;, err
			}
			return fmt.Sprintf(&quot;%d&quot;, v), nil
		},
	}
}

// tools 是全局注册表，新增工具只需在这里加一项。
var tools = map[string]*tool{
	&quot;get_current_time&quot;: {
		description: &quot;获取当前的日期和时间&quot;,
		parameters:  map[string]any{&quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: map[string]any{}},
		run: func(args json.RawMessage) (string, error) {
			return time.Now().Format(&quot;2006-01-02 15:04:05&quot;), nil
		},
	},
	&quot;add&quot;: binaryOp(&quot;add&quot;, &quot;计算两个整数 a 与 b 的和&quot;,
		func(a, b int) (int, error) { return a + b, nil }),
	&quot;multiply&quot;: binaryOp(&quot;multiply&quot;, &quot;计算两个整数 a 与 b 的乘积&quot;,
		func(a, b int) (int, error) { return a * b, nil }),
	&quot;divide&quot;: binaryOp(&quot;divide&quot;, &quot;计算两个整数 a 除以 b 的商（整除）&quot;,
		func(a, b int) (int, error) {
			if b == 0 {
				return 0, fmt.Errorf(&quot;除数不能为 0&quot;) // 刻意制造一次&quot;工具执行失败&quot;，演示错误回喂
			}
			return a / b, nil
		}),
}

// toolDefs 把注册表转成请求里的 tools 数组。
func toolDefs() []Tool {
	var out []Tool
	for name, t := range tools {
		var td Tool
		td.Type = &quot;function&quot;
		td.Function.Name = name
		td.Function.Description = t.description
		td.Function.Parameters = t.parameters
		out = append(out, td)
	}
	return out
}

// ===========================================
// Agent 循环
// ===========================================

const maxRounds = 8 // 保险丝：防止模型陷入&quot;调用-报错-再调用&quot;的死循环

func main() {
	if len(os.Args) &gt; 1 {
		runAgent(strings.Join(os.Args[1:], &quot; &quot;))
		return
	}
	// 默认问题：多轮 + 刻意触发一次除零错误 + 顺带取时间
	runAgent(&quot;请先计算 10 除以 0，再计算 10 除以 2，然后把两个结果与当前时间一起告诉我。&quot;)
}

func runAgent(userPrompt string) {
	messages := []Message{{Role: &quot;user&quot;, Content: userPrompt}}
	fmt.Println(&quot;🧑 用户:&quot;, userPrompt)
	fmt.Println(strings.Repeat(&quot;-&quot;, 56))

	for round := 1; round &lt;= maxRounds; round++ {
		resp, err := chat(messages)
		if err != nil {
			fmt.Println(&quot;❌ 请求失败:&quot;, err)
			return
		}
		if len(resp.Choices) == 0 {
			fmt.Println(&quot;❌ 无响应&quot;)
			return
		}

		msg := resp.Choices[0].Message
		finish := resp.Choices[0].FinishReason
		messages = append(messages, msg)

		// 铁律：以 tool_calls 非空 / finish_reason==&quot;tool_calls&quot; 为准
		if len(msg.ToolCalls) == 0 || finish != &quot;tool_calls&quot; {
			fmt.Printf(&quot;🤖 第 %d 轮 最终回答: %s\n&quot;, round, msg.Content)
			fmt.Printf(&quot;📊 tokens: prompt=%d completion=%d total=%d\n&quot;,
				resp.Usage.PromptTokens, resp.Usage.CompletionTokens,
				resp.Usage.PromptTokens+resp.Usage.CompletionTokens)
			return
		}

		fmt.Printf(&quot;🔧 第 %d 轮 模型请求 %d 个工具:\n&quot;, round, len(msg.ToolCalls))
		for _, tc := range msg.ToolCalls {
			result, err := runTool(tc)
			if err != nil {
				result = fmt.Sprintf(&quot;工具执行出错：%v。请修正参数后重试，或放弃这一步。&quot;, err)
			}
			fmt.Printf(&quot;   - %s(%s) -&gt; %s\n&quot;, tc.Function.Name, tc.Function.Arguments, result)
			// 结果必须以 role=&quot;tool&quot; + 对应 ID 回传（第 2 篇的机制）
			messages = append(messages, Message{
				Role:       &quot;tool&quot;,
				ToolCallID: tc.ID,
				Content:    result,
			})
		}
		messages = trimHistory(messages, resp.Usage.PromptTokens)
	}
	fmt.Println(&quot;⚠️ 达到最大轮数，可能陷入循环。&quot;)
}

func runTool(tc ToolCall) (string, error) {
	t, ok := tools[tc.Function.Name]
	if !ok {
		return &quot;&quot;, fmt.Errorf(&quot;未知工具 %q（模型幻觉了工具名）&quot;, tc.Function.Name)
	}
	return t.run(json.RawMessage(tc.Function.Arguments))
}

// trimHistory：极简历史裁剪——当某轮 prompt 已经很长时，丢掉最早的
// 一轮完整对话（该轮 user 及其后的 assistant 与 role=tool 消息），
// 保留最近的上下文。生产中一般按 token 阈值触发并配合摘要压缩，
// 这里只演示思路。
func trimHistory(messages []Message, lastPromptTokens int) []Message {
	if lastPromptTokens &lt; 4000 || len(messages) &lt;= 4 {
		return messages
	}
	// 定位最早一轮的边界：从第一条 user 开始，到下一个 user 之前结束
	// （期间是这条 user 引发的 assistant 与 role=tool 消息，必须整组删除，
	// 否则会留下无 assistant 对应的孤儿 tool 消息，上游会校验失败）。
	start := 0
	for start &lt; len(messages) &amp;&amp; messages[start].Role != &quot;user&quot; {
		start++
	}
	end := len(messages)
	for i := start + 1; i &lt; len(messages); i++ {
		if messages[i].Role == &quot;user&quot; {
			end = i
			break
		}
	}
	if start &gt;= len(messages) || end == len(messages) {
		// 找不到第二条 user：当前只有一轮对话在进行中（如本 demo 的
		// 单个问题多轮调工具），此时不裁剪，避免删掉正在使用的上下文。
		return messages
	}
	fmt.Printf(&quot;   ✂️ 历史已超过 %d tokens，丢弃最早一轮对话（%d 条消息）\n&quot;, lastPromptTokens, end-start)
	return append([]Message{}, messages[end:]...)
}

// ===========================================
// HTTP（与 Ollama 通信）
// ===========================================

var httpClient = &amp;http.Client{Timeout: 5 * time.Minute} // 请求超时：上游卡住时不至于无限挂起

func chat(messages []Message) (ChatResponse, error) {
	reqBody := ChatRequest{
		Model:    &quot;llama3.1:8b&quot;,
		Messages: messages,
		Tools:    toolDefs(),
		Stream:   false,
	}
	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		return ChatResponse{}, err
	}
	resp, err := httpClient.Post(&quot;http://localhost:11434/v1/chat/completions&quot;, &quot;application/json&quot;, bytes.NewBuffer(jsonData))
	if err != nil {
		return ChatResponse{}, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		b, _ := io.ReadAll(resp.Body)
		return ChatResponse{}, fmt.Errorf(&quot;upstream %d: %s&quot;, resp.StatusCode, string(b))
	}
	body, _ := io.ReadAll(resp.Body)
	var result ChatResponse
	if err := json.Unmarshal(body, &amp;result); err != nil {
		return ChatResponse{}, fmt.Errorf(&quot;解析失败，原始响应: %s&quot;, string(body))
	}
	return result, nil
}</code></pre>

</details>

<h2 id="6-运行结果-本机实测">6. 运行结果（本机实测）</h2>

<pre><code class="language-bash">go run main.go
</code></pre>

<p>真实输出（Ollama 0.33.3 + llama3.1:8b，2026-09-08 实测）：</p>

<pre><code>🧑 用户: 请先计算 10 除以 0，再计算 10 除以 2，然后把两个结果与当前时间一起告诉我。
--------------------------------------------------------
🔧 第 1 轮 模型请求 3 个工具:
   - divide({&quot;a&quot;:10,&quot;b&quot;:0}) -&gt; 工具执行出错：除数不能为 0。请修正参数后重试，或放弃这一步。
   - divide({&quot;a&quot;:10,&quot;b&quot;:2}) -&gt; 5
   - get_current_time({}) -&gt; 2026-09-08 16:16:50
🤖 第 2 轮 最终回答: 当前时间与计算结果为：
5
2026-09-08 16:16:50
📊 tokens: prompt=197 completion=22 total=219
</code></pre>

<p>值得注意的三点：</p>

<ol>
<li><strong>一轮并行调了 3 个工具</strong>：<code>divide(10,0)</code> 与 <code>divide(10,2)</code> 和 <code>get_current_time</code> 在同一个 assistant 消息里返回，循环把它们全部执行并各自回传 <code>role=tool</code>；</li>
<li><strong>错误被正确回喂</strong>：<code>divide(10,0)</code> 的报错进入了对话历史，模型在下一轮明确感知并处理；</li>
<li><strong>tokens 统计可见循环成本</strong>：最后一轮 <code>prompt=197</code> 是把全部历史重发的总长——轮次越多、历史越长，这个数越大，印证第 4 节的裁剪必要性。</li>
</ol>

<h2 id="7-坑与对照-实测验证">7. 坑与对照（实测验证）</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>原因</th>
<th>处理</th>
</tr>
</thead>

<tbody>
<tr>
<td>模型一轮返回多个 <code>tool_calls</code></td>
<td>并行工具调用是正常行为</td>
<td>全部执行、逐条回传，不要只处理第一个（第 2 篇的&rdquo;只取 <code>[0]</code>&ldquo;在这里会丢结果）</td>
</tr>

<tr>
<td>模型把参数填错类型/漏字段</td>
<td>本地小模型的 Schema 遵循度有限</td>
<td>描述写清楚；解析失败时把错误回喂，模型通常能自纠</td>
</tr>

<tr>
<td>模型乱编工具名</td>
<td>幻觉</td>
<td><code>runTool</code> 对未知名字返回&rdquo;未知工具&rdquo;，不要 panic</td>
</tr>

<tr>
<td>报错回喂后模型选择放弃</td>
<td>放弃也是合法策略</td>
<td>业务上&rdquo;必须成功&rdquo;时，靠提示词强调或代码层强制重试，不要假设模型会自动坚持</td>
</tr>

<tr>
<td>上下文越长越慢</td>
<td>每轮全量重发历史</td>
<td>阈值裁剪（本篇）；摘要压缩属生产做法，不在本系列范围</td>
</tr>

<tr>
<td>死循环风险</td>
<td>模型反复请求同一工具</td>
<td><code>maxRounds</code> 保险丝 + 报错文案引导其收敛</td>
</tr>
</tbody>
</table>

<h2 id="8-刻意简化-vs-生产做法">8. 刻意简化 vs 生产做法</h2>

<table>
<thead>
<tr>
<th>刻意简化的地方</th>
<th>生产环境的做法</th>
</tr>
</thead>

<tbody>
<tr>
<td>历史裁剪只删最早一轮</td>
<td>token 感知的滑动窗口 + 摘要压缩</td>
</tr>

<tr>
<td>工具执行同步、串行</td>
<td>异步执行池、超时与并发控制</td>
</tr>

<tr>
<td><code>maxRounds</code> 硬上限</td>
<td>更细的重试策略（次数/退避/放弃条件）</td>
</tr>

<tr>
<td>错误只回喂一句话</td>
<td>结构化错误码 + 让模型可读的上下文</td>
</tr>

<tr>
<td>无状态、单会话</td>
<td>会话隔离见第 5 篇；生产级多会话管理不在本系列范围</td>
</tr>
</tbody>
</table>

<h2 id="faq">FAQ</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>结果对不上工具调用</td>
<td>检查 <code>tool_call_id</code> 是否逐条对齐（第 2 篇机制）</td>
</tr>

<tr>
<td>多个工具只执行了第一个</td>
<td>循环里遍历全部 <code>ToolCalls</code>，不要只取 <code>[0]</code></td>
</tr>

<tr>
<td>换模型后行为变差</td>
<td>本地小模型遵循度参差，先试 <code>llama3.1:8b</code>；Qwen 系列的坑见第 1 篇附录 A</td>
</tr>
</tbody>
</table>

<h2 id="结论">结论</h2>

<ol>
<li><strong>Agent = 循环</strong>：<code>while 模型还想要工具</code>，把&rdquo;一次调用&rdquo;变成&rdquo;自主多轮&rdquo;只多了十几行；</li>
<li><strong>错误回喂让 Agent 有韧性</strong>：报错作为工具结果进入历史，模型自纠或放弃都由它决定；</li>
<li><strong>上下文是循环的第一成本</strong>：先有阈值保险丝，再做记忆（第 5 篇）；</li>
<li>本篇仍是命令行一次性运行，<strong>把它变成常驻 HTTP 服务、支持流式输出</strong>是第 4 篇的主题。</li>
</ol>

<p>下一篇预告：<strong>《服务与迁移：把 Agent 变成流式 API，可切云端》</strong>。</p>
]]></content:encoded>
      <description><![CDATA[把单轮调用升级为循环：多工具注册、一轮多个并行 tool_calls、工具报错回喂、历史裁剪——让 Agent 自己决定调几次工具。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Agent]]></category>
      <category><![CDATA[LLM]]></category>
      <dc:relation><![CDATA[series:go-agent]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[最小代码：单工具一轮调用的完整闭环]]></title>
      <link>https://moongate.top/docs/minimal-agent</link>
      <guid isPermaLink="true">https://moongate.top/docs/minimal-agent</guid>
      <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>本篇是<strong>刻意精简的最小案例</strong>：一个工具、单轮调用、约 200 行 Go（含注释），只用标准库（<code>net/http</code>、<code>encoding/json</code>），跑通“模型点名 → Go 执行 → 结果回传 → 模型作答”的完整闭环。</p>

<ul>
<li>目标读者：有 Go 基础、第一次写 Agent 工具调用（前提：已按第 1 篇装好 Ollama 与 <code>llama3.1:8b</code>）</li>
<li>代码：完整代码内嵌在本篇 §2、§3；保存为 <code>main.go</code> 后在本目录执行 <code>go run main.go</code></li>
<li>运行要求：Go 1.27+</li>
</ul>

<h2 id="1-接口基调-v1-近似兼容-先知道再写代码">1. 接口基调：/v1 近似兼容（先知道再写代码）</h2>

<blockquote>
<p>急着先跑起来？可以直接跳到第 2 节，用 curl / Go 跑通一次对话后再回来读本节的兼容差异细节。</p>
</blockquote>

<p>本篇代码走 <code>POST http://localhost:11434/v1/chat/completions</code>——它只是与 OpenAI SDK 语法最接近，官方定位是 <strong>OpenAI 兼容层，而非逐字段等同</strong>。下表差异用 Ollama 0.33.3 实测并对照官方兼容文档核对（差异点随版本漂移——连流式报文格式都是后来才对齐的）；代码篇与后续第 4 篇（服务化、迁移云端）都会用到它：</p>

<table>
<thead>
<tr>
<th>差异点</th>
<th>Ollama（0.33.3 实测）</th>
<th>官方 OpenAI</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>tool_choice</code> 参数</td>
<td>部分支持且不稳定（0.33.3 实测：<code>required</code> 可强制调用、<code>none</code> 不生效；官方兼容文档未列入支持字段，上游仍在完善：<a href="https://github.com/ollama/ollama/issues/17921" target="_blank">#17921</a>、<a href="https://github.com/ollama/ollama/issues/11171" target="_blank">#11171</a>）</td>
<td>支持 <code>auto</code>/<code>required</code>/指定函数，强制调用场景常用</td>
</tr>

<tr>
<td>空 <code>tools: []</code></td>
<td>返回 200，等同没传工具</td>
<td>通常报错或要求省略该字段（多个 SDK 专门写 workaround 规避）</td>
</tr>

<tr>
<td>工具调用时 <code>message.content</code></td>
<td>空字符串 <code>&quot;&quot;</code></td>
<td><code>null</code></td>
</tr>

<tr>
<td><code>finish_reason</code></td>
<td>工具调用时为 <code>tool_calls</code>（本版本已与 OpenAI 一致）</td>
<td><code>tool_calls</code></td>
</tr>

<tr>
<td><code>n</code> / <code>user</code> / <code>logit_bias</code></td>
<td>不支持</td>
<td>支持</td>
</tr>

<tr>
<td>流式报文</td>
<td>早期与 OpenAI 不一致（Ollama <a href="https://github.com/ollama/ollama/pull/17485" target="_blank">PR #17485</a> 后才对齐 <code>choices[].delta</code>），旧版本仍有差异</td>
<td><code>choices[].delta</code></td>
</tr>
</tbody>
</table>
<p>写代码时的三条铁律（本篇代码已经在遵守）：</p>

<ul>
<li>判&rdquo;是否要调工具&rdquo;以 <strong><code>tool_calls</code> 非空 / <code>finish_reason == &quot;tool_calls&quot;</code></strong> 为准，不要只依赖 <code>content</code>；</li>
<li>判空时同时兼容 <code>content == &quot;&quot;</code> 与 <code>content == null</code>；</li>
<li>无工具时<strong>省略 <code>tools</code> 字段</strong>，不要传 <code>tools: []</code>；</li>
<li>需要&rdquo;强制调用某工具&rdquo;时别指望 <code>tool_choice</code>（0.33.3 实测 required 可强制、none 不生效，字段支持不稳定），改用提示词约束或原生 <code>/api/chat</code>（第 4 篇第 1 节会对比原生 <code>/api/chat</code> 的报文差异，但服务为了可切云端统一走 <code>/v1</code>）。</li>
</ul>

<p>参考：<a href="https://docs.ollama.com/api/openai-compatibility" target="_blank">Ollama OpenAI compatibility 官方文档</a></p>

<hr>

<h2 id="2-纯文本起步-核心逻辑约-20-行跑通一次对话">2. 纯文本起步：核心逻辑约 20 行跑通一次对话</h2>

<p>先用 curl 徒手跑通一次——与语言无关，也最容易确认服务与模型就绪：</p>

<pre><code class="language-bash">curl -s http://localhost:11434/v1/chat/completions \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;model&quot;:&quot;llama3.1:8b&quot;,&quot;messages&quot;:[{&quot;role&quot;:&quot;user&quot;,&quot;content&quot;:&quot;你好，用一句话介绍你自己。&quot;}]}'
</code></pre>

<p>返回的 JSON 长这样（格式化后）：</p>

<pre><code class="language-json">{
  &quot;id&quot;: &quot;chatcmpl-9&quot;,
  &quot;model&quot;: &quot;llama3.1:8b&quot;,
  &quot;choices&quot;: [
    {
      // ← Go: ChatResponse.Choices[]
      &quot;message&quot;: {
        // ← Go: ChatResponse.Choices[].Message
        &quot;role&quot;: &quot;assistant&quot;,
        &quot;content&quot;: &quot;我是语言模型，能理解和生成汉语。&quot; // ← 你要的答案
      },
      &quot;finish_reason&quot;: &quot;stop&quot; // ← 模型收工了（工具调用时会变成 &quot;tool_calls&quot;，见 §3）
    }
  ],
  &quot;usage&quot;: {
    // ← token 计数，第 3 篇&quot;历史全量重发&quot;的成本来源
    &quot;prompt_tokens&quot;: 19,
    &quot;completion_tokens&quot;: 13
  }
}
</code></pre>

<p>你只需关心 <code>choices[0].message.content</code>——这就是答案。后面的 Go 代码就照着这个 JSON 结构定义结构体。</p>

<p>在给代码&rdquo;加工具&rdquo;之前，先想清楚一个问题：<strong>什么时候其实不需要 Agent？</strong> 简单问答/闲聊直接用 <code>/api/chat</code>（不带 <code>tools</code>）即可；纯文本补全用 <code>/api/generate</code> 更轻（无聊天模板与工具解析开销）；确定性任务（查表、格式化）甚至不用模型。只有任务需要<strong>真实世界的副作用或数据</strong>（查时间、查库、执行命令）且执行路径无法预先写死时，才值得上&rdquo;模型点名 → 代码执行 → 结果回传&rdquo;的回路——第 3 篇会说明每轮要全量重发历史，这个判断越早做越省 token。</p>

<p>而<strong>纯文本聊天正是这条轻量路径的最简形态</strong>：同样把 <code>messages</code> 数组发到 <code>/v1/chat/completions</code>，只是请求里没有 <code>tools</code> 字段、响应里也只有 <code>content</code> 没有 <code>tool_calls</code>。</p>

<p>完整代码（约 60 行、只有标准库）：</p>

<details>
<summary>main.go 全文（点击展开）</summary>

<pre><code class="language-go">// 第 2 篇（纯文本起步版）演示：核心逻辑约 20 行跑通一次对话
//
// 与工具版的关系：纯文本聊天是工具调用的&quot;子集&quot;——同一个
// /v1/chat/completions 接口、同样的 messages 数组，只是：
//  1. 请求里不带 tools 字段（遵守&quot;无工具时省略 tools&quot;铁律）；
//  2. 响应里只有 content，没有 tool_calls；
//  3. 多轮对话 = 不断往 messages 里追加 {role,content}，再整段重发。
package main

import (
	&quot;bytes&quot;
	&quot;encoding/json&quot;
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;net/http&quot;
)

type Message struct {
	Role    string `json:&quot;role&quot;`
	Content string `json:&quot;content&quot;`
}

type ChatRequest struct {
	Model       string    `json:&quot;model&quot;`
	Messages    []Message `json:&quot;messages&quot;`
	Temperature float64   `json:&quot;temperature&quot;`
}

type Choice struct {
	Message      Message `json:&quot;message&quot;`
	FinishReason string  `json:&quot;finish_reason&quot;`
}

type ChatResponse struct {
	Choices []Choice `json:&quot;choices&quot;`
}

func main() {
	messages := []Message{
		{Role: &quot;user&quot;, Content: &quot;你好，请用一句话介绍你自己。&quot;},
	}

	reqBody := ChatRequest{
		Model:       &quot;llama3.1:8b&quot;,
		Messages:    messages,
		Temperature: 0, // 演示用贪心解码，输出稳定可复现
	}
	jsonData, _ := json.Marshal(reqBody)

	resp, err := http.Post(&quot;http://localhost:11434/v1/chat/completions&quot;,
		&quot;application/json&quot;, bytes.NewBuffer(jsonData))
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	var result ChatResponse
	if err := json.Unmarshal(body, &amp;result); err != nil {
		fmt.Printf(&quot;解析失败，原始响应: %s\n&quot;, string(body))
		panic(err)
	}
	if len(result.Choices) == 0 {
		fmt.Printf(&quot;无响应，原始响应: %s\n&quot;, string(body))
		return
	}

	c := result.Choices[0]
	fmt.Println(&quot;🧑 用户:&quot;, messages[0].Content)
	fmt.Println(&quot;🤖&quot;, c.Message.Content)
	fmt.Println(&quot;   finish_reason:&quot;, c.FinishReason)
}</code></pre>

</details>

<p>三个要点：</p>

<ul>
<li>请求体只有 <code>model</code> + <code>messages</code>，没有 <code>tools</code>——这正是第 1 节铁律&rdquo;无工具时省略 <code>tools</code> 字段&rdquo;的落地；</li>
<li>响应读 <code>choices[0].message.content</code> 与 <code>finish_reason</code>（这里是 <code>stop</code>）；</li>
<li><strong>多轮对话 = 往 <code>messages</code> 里追加再整段重发</strong>，这是后面所有示例的公共基础。</li>
</ul>

<p>运行：</p>

<pre><code class="language-bash">go run main.go
</code></pre>

<p>真实输出（Ollama 0.33.3 + llama3.1:8b，2026-09 实测）：</p>

<pre><code>🧑 用户: 你好，请用一句话介绍你自己。
🤖 你好！我是 LLaMA，一个由 Meta 开发的基于人工智能的语言模型，能够理解和生成人类语言。
   finish_reason: stop
</code></pre>

<blockquote>
<p>从这一节到下一节只差&rdquo;三件事&rdquo;：请求加 <code>tools</code> 字段、响应解析 <code>tool_calls</code>、执行结果用 <code>role=&quot;tool&quot;</code> 回传。工具版的结构体与 <code>sendRequest</code> 和本节几乎一模一样——所以教程直接从工具讲起也成立，但先看纯文本更容易建立直觉。</p>
</blockquote>

<hr>

<h2 id="3-go-实现-在纯文本上加工具">3. Go 实现（在纯文本上加工具）</h2>

<p>这是<strong>刻意精简的最小案例</strong>：一个工具、单轮调用、约 200 行（含注释），只用 Go 标准库（<code>net/http</code>、<code>encoding/json</code>）。</p>

<p>设计取舍如下，先看懂原理，再补工程化：</p>

<table>
<thead>
<tr>
<th>刻意简化的地方</th>
<th>生产环境的做法</th>
</tr>
</thead>

<tbody>
<tr>
<td>只支持单个工具、单轮调用</td>
<td>多工具注册 + while 循环，直到模型不再请求工具</td>
</tr>

<tr>
<td>HTTP 错误直接 <code>panic</code></td>
<td>返回 <code>error</code> 并优雅降级/重试</td>
</tr>

<tr>
<td><code>json.Marshal</code>、<code>io.ReadAll</code> 错误忽略</td>
<td>逐一处理并带上上下文</td>
</tr>

<tr>
<td>硬编码模型名 <code>llama3.1:8b</code></td>
<td>配置化（flag / 环境变量）</td>
</tr>

<tr>
<td>固定 30s 超时、无并发控制</td>
<td><code>http.Client</code> 超时 + 连接池</td>
</tr>

<tr>
<td>只读 <code>message.content</code>/<code>tool_calls</code>，不判 <code>finish_reason</code></td>
<td>按本篇第 1 节兼容差异清单处理（<code>tool_choice</code>、空 <code>tools</code> 等）</td>
</tr>
</tbody>
</table>

<h3 id="完整代码-main-go">完整代码（main.go）</h3>

<p>先看请求和响应的原始 JSON——<strong>代码里的结构体就是照着这两个 JSON 定义的</strong>：</p>

<p>请求（带 <code>tools</code> 数组）：</p>

<pre><code class="language-json">{
  &quot;model&quot;: &quot;llama3.1:8b&quot;,
  &quot;messages&quot;: [{ &quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;现在几点了？&quot; }],
  &quot;tools&quot;: [
    {
      // ← Go: ChatRequest.Tools []Tool
      &quot;type&quot;: &quot;function&quot;,
      &quot;function&quot;: {
        &quot;name&quot;: &quot;get_current_time&quot;,
        &quot;description&quot;: &quot;获取当前时间&quot;,
        &quot;parameters&quot;: { &quot;type&quot;: &quot;object&quot;, &quot;properties&quot;: {} }
      }
    }
  ]
}
</code></pre>

<p>响应（Ollama 0.33.3 实测）：</p>

<pre><code class="language-json">{
  &quot;choices&quot;: [
    {
      &quot;message&quot;: {
        // ← Go: ChatResponse.Choices[].Message
        &quot;role&quot;: &quot;assistant&quot;,
        &quot;content&quot;: &quot;&quot;, // ⚠️ 空字符串，不是 null
        &quot;tool_calls&quot;: [
          {
            // ← Go: Message.ToolCalls []ToolCall
            &quot;id&quot;: &quot;call_dlj5358x&quot;, // ⚠️ 关联 ID：执行结果回传时必须带上这个
            &quot;function&quot;: {
              &quot;name&quot;: &quot;get_current_time&quot;,
              &quot;arguments&quot;: &quot;{}&quot; // ⚠️ 这是字符串！不是对象！需要 json.RawMessage 再解析
            }
          }
        ]
      },
      &quot;finish_reason&quot;: &quot;tool_calls&quot; // ⚠️ 不是 &quot;stop&quot;（第 3 篇循环判据）
    }
  ],
  &quot;usage&quot;: { &quot;prompt_tokens&quot;: 146, &quot;completion_tokens&quot;: 14 }
}
</code></pre>

<p>看完这两段 JSON，再看后面的 Go 代码：<code>ToolCall.Function.Arguments</code> 为什么要用 <code>json.RawMessage</code> 再解析一次、<code>ToolCallID</code> 是干什么用的，就一目了然了。</p>

<details>
<summary>main.go 全文（点击展开）</summary>

<pre><code class="language-go">package main

import (
	&quot;bytes&quot;
	&quot;encoding/json&quot;
	&quot;fmt&quot;
	&quot;io&quot;
	&quot;net/http&quot;
	&quot;time&quot;
)

// ===========================================
// 数据结构定义（对应 Ollama API 的 JSON 格式）
// ===========================================

// Message 表示对话中的一条消息
type Message struct {
	Role       string     `json:&quot;role&quot;`
	Content    string     `json:&quot;content,omitempty&quot;`
	ToolCalls  []ToolCall `json:&quot;tool_calls,omitempty&quot;`
	ToolCallID string     `json:&quot;tool_call_id,omitempty&quot;`
}

// ToolCall 表示 AI 请求调用的一个工具
type ToolCall struct {
	ID       string `json:&quot;id&quot;`
	Type     string `json:&quot;type&quot;`
	Function struct {
		Name      string `json:&quot;name&quot;`
		Arguments string `json:&quot;arguments&quot;`
	} `json:&quot;function&quot;`
}

// Tool 表示我们提供给 AI 的一个可用工具
type Tool struct {
	Type     string   `json:&quot;type&quot;`
	Function Function `json:&quot;function&quot;`
}

// Function 表示工具函数的定义
type Function struct {
	Name        string                 `json:&quot;name&quot;`
	Description string                 `json:&quot;description&quot;`
	Parameters  map[string]interface{} `json:&quot;parameters&quot;`
}

// ChatRequest 表示发送给 Ollama 的请求
type ChatRequest struct {
	Model    string    `json:&quot;model&quot;`
	Messages []Message `json:&quot;messages&quot;`
	Tools    []Tool    `json:&quot;tools&quot;`
	Stream   bool      `json:&quot;stream&quot;`
}

// Choice 表示一次生成的选择（含完成原因）
type Choice struct {
	Message      Message `json:&quot;message&quot;`
	FinishReason string  `json:&quot;finish_reason&quot;`
}

// ChatResponse 表示 Ollama 返回的响应
type ChatResponse struct {
	Choices []Choice `json:&quot;choices&quot;`
}

// ===========================================
// 工具函数（实际执行的逻辑）
// ===========================================

// getCurrentTime 是我们实现的工具函数
// 当 AI 决定调用 &quot;get_current_time&quot; 时，这个函数会被执行
// args 是 AI 传入的参数（本例中没有参数，所以忽略）
func getCurrentTime(args json.RawMessage) string {
	return time.Now().Format(&quot;2006-01-02 15:04:05&quot;)
}

// toolMap 是工具注册表，用于根据名称查找对应的函数
// 当 AI 返回 tool_calls 时，我们通过这个表找到要执行的函数
var toolMap = map[string]func(json.RawMessage) string{
	&quot;get_current_time&quot;: getCurrentTime,
}

// ===========================================
// 主程序（演示 Tool Calling 的完整流程）
// ===========================================

func main() {
	// 第一步：准备用户消息
	// 这是用户问 AI 的问题
	messages := []Message{
		{Role: &quot;user&quot;, Content: &quot;现在几点了？告诉我当前的具体时间。&quot;},
	}

	// 第二步：定义可用工具
	// 告诉 AI 有哪些工具可以使用，以及每个工具的功能和参数
	tools := []Tool{
		{
			Type: &quot;function&quot;,
			Function: Function{
				Name:        &quot;get_current_time&quot;,
				Description: &quot;获取当前的日期和时间&quot;,
				Parameters: map[string]interface{}{
					&quot;type&quot;:       &quot;object&quot;,
					&quot;properties&quot;: map[string]interface{}{},
				},
			},
		},
	}

	// 第三步：发送请求给 Ollama
	// 把用户消息和工具列表一起发给模型
	resp := sendRequest(messages, tools)
	if len(resp.Choices) == 0 {
		fmt.Println(&quot;无响应&quot;)
		return
	}

	// 获取 AI 的回复
	assistantMsg := resp.Choices[0].Message
	messages = append(messages, assistantMsg)

	// 第四步：检查 AI 是否要调用工具
	if len(assistantMsg.ToolCalls) &gt; 0 {
		// AI 决定调用工具
		toolCall := assistantMsg.ToolCalls[0]
		fmt.Println(&quot;🔧 模型决定调用工具:&quot;, toolCall.Function.Name)

		// 第五步：执行工具
		// 通过工具注册表找到对应的函数并执行（查不到多半是模型幻觉了工具名，别 panic）
		fn, ok := toolMap[toolCall.Function.Name]
		if !ok {
			fmt.Println(&quot;⚠️ 未知工具，跳过:&quot;, toolCall.Function.Name)
			return
		}
		result := fn(json.RawMessage(toolCall.Function.Arguments))

		// 第六步：把工具结果作为新消息加入对话
		// 注意：Role 必须是 &quot;tool&quot;，ToolCallID 必须与 AI 请求的 ID 一致
		messages = append(messages, Message{
			Role:       &quot;tool&quot;,
			ToolCallID: toolCall.ID,
			Content:    result,
		})

		fmt.Println(&quot;✅ 工具结果:&quot;, result)

		// 第七步：把包含工具结果的对话再次发给模型
		// 模型会根据工具结果生成最终回答
		finalResp := sendRequest(messages, tools)
		if len(finalResp.Choices) &gt; 0 {
			fmt.Println(&quot;💬 最终回答:&quot;, finalResp.Choices[0].Message.Content)
		}
	} else {
		// AI 没有调用工具，直接输出回答
		fmt.Println(&quot;💬 回答:&quot;, assistantMsg.Content)
	}
}

// ===========================================
// HTTP 请求函数（与 Ollama 通信）
// ===========================================

// sendRequest 发送请求到 Ollama API
// 使用 OpenAI 兼容的格式：/v1/chat/completions
func sendRequest(messages []Message, tools []Tool) ChatResponse {
	// 构建请求体
	reqBody := ChatRequest{
		Model:    &quot;llama3.1:8b&quot;, // 使用的模型
		Messages: messages,      // 对话历史
		Tools:    tools,         // 可用工具
		Stream:   false,         // 不使用流式输出
	}

	// 序列化为 JSON
	jsonData, _ := json.Marshal(reqBody)

	// 发送 POST 请求到 Ollama（用带超时的 Client 替代裸 http.Post，避免上游卡住时无限挂起）
	client := &amp;http.Client{Timeout: 30 * time.Second}
	resp, err := client.Post(
		&quot;http://localhost:11434/v1/chat/completions&quot;,
		&quot;application/json&quot;,
		bytes.NewBuffer(jsonData),
	)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	// 读取响应
	body, _ := io.ReadAll(resp.Body)

	// 解析 JSON 响应
	// 注意：Ollama 报错时返回的不是标准结构，直接解析会得到空响应，
	// 静默输出&quot;无响应&quot;会让新手误以为模型没装好，因此失败时打印原始内容
	var result ChatResponse
	if err := json.Unmarshal(body, &amp;result); err != nil {
		fmt.Printf(&quot;解析失败，原始响应: %s\n&quot;, string(body))
		panic(err)
	}
	return result
}</code></pre>

</details>

<h3 id="代码讲解">代码讲解</h3>

<p>代码由三部分组成，理解它们的职责即可：</p>

<h4 id="1-数据结构-文件前半段">1. 数据结构（文件前半段）</h4>

<p><code>Message</code>、<code>ToolCall</code>、<code>Tool</code> 等结构体与 Ollama API 的 JSON 字段一一对应（靠 <code>json:&quot;...&quot;</code> 标签）。其中最容易困惑的是 <code>Message</code>：</p>

<ul>
<li><code>role=&quot;assistant&quot;</code> 的消息里带 <code>tool_calls</code>（AI 想调什么工具）</li>
<li><code>role=&quot;tool&quot;</code> 的消息里带 <code>tool_call_id</code>（工具执行结果，回传给 AI）</li>
</ul>

<h4 id="2-工具定义与注册表">2. 工具定义与注册表</h4>

<pre><code class="language-go">// 工具实现：模型调用 &quot;get_current_time&quot; 时执行这里
func getCurrentTime(args json.RawMessage) string {
	return time.Now().Format(&quot;2006-01-02 15:04:05&quot;)
}

// 注册表：模型只发工具&quot;名字&quot;，程序靠这张表找到对应的 Go 函数
var toolMap = map[string]func(json.RawMessage) string{
	&quot;get_current_time&quot;: getCurrentTime,
}
</code></pre>

<h4 id="3-主流程-main-对应下方流程示意">3. 主流程 <code>main()</code>（对应下方流程示意）</h4>

<p><code>main()</code> 内注释按「第一步 ~ 第七步」执行，与下方流程一一对应：</p>

<pre><code>1 用户输入问题         → Go 程序准备 messages（role=&quot;user&quot;）
2 Go 程序定义工具清单   → POST /v1/chat/completions（messages + tools）发给 Ollama
3 Ollama 返回          → assistant 消息 + tool_calls（判定：tool_calls 非空）
4 Go 程序本地执行       → 按工具名查 toolMap，调用对应 Go 函数（模型只&quot;点名&quot;，不执行）
5 Go 程序回传结果       → 包装成 role=&quot;tool&quot;，ToolCallID 对齐模型的调用 ID
6 Ollama 返回          → 最终回答 content（基于真实工具结果生成）
7 Go 程序打印回答       → 用户看到结果
</code></pre>

<blockquote>
<p>关键点：第六步的 <code>ToolCallID</code> 必须与模型请求里的 <code>ID</code> 一致，模型才能把结果对应到那一次调用。整套机制的核心是——<strong>模型不执行工具，只&rdquo;点名&rdquo;；执行永远发生在本地代码</strong>。</p>
</blockquote>

<h3 id="http-请求函数-sendrequest">HTTP 请求函数 sendRequest</h3>

<p>全程序唯一与 Ollama 通信的地方：</p>

<ul>
<li>请求体里 <code>Model</code> 指定模型名、<code>Messages</code> 带完整对话历史、<code>Tools</code> 带工具清单</li>
<li>请求发到 OpenAI 兼容接口 <code>POST http://localhost:11434/v1/chat/completions</code></li>
<li>响应解析后返回 <code>ChatResponse</code>，<code>main()</code> 从 <code>Choices[0].Message</code> 取 AI 回复</li>
</ul>

<h3 id="生产化起步-四个小改造-从-200-行到工程的过渡态">生产化起步：四个小改造（从 200 行到工程的过渡态）</h3>

<p>上面表格只给了方向，这里直接给最小的改造示例；更完整的演进按第 3、4 篇逐步展开。</p>

<p><strong>① 固定 30s 超时 → 可配置超时预算</strong></p>

<p>最小版已经用带 30s 超时的 <code>http.Client</code> 发请求（见上方完整代码里的 <code>sendRequest</code>），这一步是把超时改成可按场景调整的预算：</p>

<pre><code class="language-go">// 改造前（最小版：固定 30 秒）
client := &amp;http.Client{Timeout: 30 * time.Second}

// 改造后（长上下文 / 云端慢推理要留足余量）
client := &amp;http.Client{Timeout: 5 * time.Minute}
</code></pre>

<blockquote>
<p>重点不是数值，而是给上游请求一个<strong>明确的超时预算</strong>：没有它，Ollama 卡住时请求会无限挂起。</p>
</blockquote>

<p><strong>② <code>panic</code> → 返回 <code>error</code></strong></p>

<pre><code class="language-go">// 改造前
func sendRequest(...) ChatResponse { ...; panic(err) }

// 改造后
func sendRequest(...) (ChatResponse, error) { ...; return ChatResponse{}, fmt.Errorf(&quot;请求失败: %w&quot;, err) }
</code></pre>

<p>主流程随之从&rdquo;崩掉&rdquo;变成&rdquo;打日志并优雅降级&rdquo;。</p>

<p><strong>③ 硬编码模型名 → 环境变量</strong></p>

<pre><code class="language-go">model := os.Getenv(&quot;OLLAMA_MODEL&quot;)
if model == &quot;&quot; {
    model = &quot;llama3.1:8b&quot;
}
</code></pre>

<p><strong>④ 单轮 → 循环（下一步就是第 3 篇）</strong></p>

<p>把&rdquo;收到 <code>tool_calls</code> → 执行 → 回传&rdquo;包进 <code>for</code>，直到响应里不再有 <code>tool_calls</code> 才收尾——这就是第 3 篇的主题；多工具注册、参数解析、错误回喂也都在那里补上。</p>

<blockquote>
<p>小结：上面四点 + 第 3 篇的循环/多工具/错误处理 + 第 4 篇的服务化/超时/并发，合起来就是&rdquo;刻意简化 vs 生产做法&rdquo;右列的落地路径——每篇只往前走一小步，不必一步到位写&rdquo;生产级&rdquo;。</p>
</blockquote>

<hr>

<h2 id="4-运行结果">4. 运行结果</h2>

<pre><code class="language-bash">go run main.go
</code></pre>

<p>成功输出：</p>

<pre><code>🔧 模型决定调用工具: get_current_time
✅ 工具结果: 2026-09-07 19:58:44
💬 最终回答: 当前时间是 2026年09月07日 19:58:44
</code></pre>

<p>Agent 真的执行了我的 Go 函数，拿到真实时间，而不是编一个答案。</p>

<h3 id="对比-同一个程序换回-qwen2-5-coder-的输出">对比：同一个程序换回 qwen2.5-coder 的输出</h3>

<p>同一个程序换回 <code>qwen2.5-coder:7b</code>（官方模板已带工具格式，模型仍未遵守），模型把工具调用写成了普通文本：</p>

<pre><code>💬 回答: {&quot;name&quot;: &quot;get_current_time&quot;, &quot;arguments&quot;: {}}
</code></pre>

<p>它&rdquo;知道&rdquo;该调工具，却没写进 <code>tool_calls</code> 字段——qwen2.5-coder + Ollama 0.33.3 的稳定复现（原因与自查见第 1 篇附录 A）。看到这种输出，先检查模型与模板，而不是怀疑自己的代码。</p>

<hr>

<h2 id="faq-常见问题速查-代码篇">FAQ：常见问题速查（代码篇）</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>原因</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>代码报 404 / 400</td>
<td>Ollama 版本过旧，<code>/v1/chat/completions</code> 接口未开启</td>
<td>升级到 &gt;= 0.3.0（见第 1 篇安装）</td>
</tr>

<tr>
<td>模型回答里出现 <code>{&quot;name&quot;: ...}</code> 字样</td>
<td>模板未带工具格式，或模型遵循度不足（qwen2.5-coder 在 0.33.3 下官方模板正确仍复现）</td>
<td>换 <code>llama3.1:8b</code>；留用 Qwen 的实录与自查见第 1 篇附录 A</td>
</tr>

<tr>
<td>切到 OpenAI / 云服务后行为不一致</td>
<td><code>/v1</code> 只是近似兼容，字段细节有差异</td>
<td>按本篇第 1 节清单自测（空 <code>tools</code>、content 空串、<code>tool_choice</code> 等）</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="结论">结论</h2>

<ol>
<li><strong>Go 做 Agent 可行</strong>：不碰 Python，约 200 行标准库代码跑通完整工具调用闭环；</li>
<li><strong>这是最小案例</strong>：只覆盖核心原理（单工具、单轮调用），多工具/多轮循环是第 3 篇的主题；</li>
<li><strong>/v1 是近似兼容</strong>：本篇代码的判定方式（<code>tool_calls</code> 非空 + 省略空 <code>tools</code>）保证在 Ollama 与 OpenAI 上行为一致；</li>
<li><strong>Qwen 的坑要按型号区分</strong>：qwen2.5-coder 是模型遵循度问题（官方模板已正确仍失败），不是&rdquo;Qwen 都不行&rdquo;——实录见第 1 篇附录 A。</li>
</ol>

<p>下一篇预告：<strong>《循环：让 Agent 自己决定调几次工具》</strong>——多工具注册、参数解析、工具报错回喂与历史裁剪。</p>
]]></content:encoded>
      <description><![CDATA[用约 200 行 Go（仅标准库）跑通“模型点名 → Go 执行 → 结果回传 → 模型作答”的最小工具调用闭环，并讲清 Ollama /v1“近似兼容”的三条铁律。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Agent]]></category>
      <category><![CDATA[LLM]]></category>
      <dc:relation><![CDATA[series:go-agent]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[环境与运维：A770 上装好并长期跑稳 Ollama]]></title>
      <link>https://moongate.top/docs/env-and-ops</link>
      <guid isPermaLink="true">https://moongate.top/docs/env-and-ops</guid>
      <pubDate>Tue, 08 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>本系列用 Go 在本地搭建 Agent，全程不碰 Python/Node（用 Python 等其他语言的读者，仍可读各篇的机制与避坑内容——工具调用机制、聊天模板、API 兼容与运维概念都与语言无关）。第 1 篇只做环境与运维、不写代码：装好 Ollama、点亮 Vulkan、拉好模型，并讲清楚长期运行会踩哪些坑、怎么监控；代码从第 2 篇开始。</p>

<ul>
<li>前置：基础 Linux 命令行，看得懂 systemd 服务概念（<code>systemctl</code>/<code>journalctl</code>）</li>
<li>提示：第 5 节「长期运维手册」为<strong>运维向内容（SRE/DevOps 视角）</strong>——纯 Go 开发读者可先跳过（不影响第 2~5 篇），但它是本系列最独特的稳定性知识，上线长期运行前务必回来精读</li>
</ul>

<blockquote>
<p>环境说明：本文基于 <strong>Ollama 0.33.3 + llama3.1:8b（A770/Vulkan，2026-09）</strong> 实测；Ollama 迭代快，环境变量以 <code>ollama serve --help</code> 为准，<code>/v1</code> 兼容字段以<a href="https://docs.ollama.com/api/openai-compatibility" target="_blank">官方 OpenAI 兼容文档</a>为准。</p>
</blockquote>

<h2 id="背景">背景</h2>

<p>我是 Nuxt + Go 全栈开发者，不想为学 Agent 额外学一门语言，于是直接用熟悉的 Go 试。</p>

<p>硬件：Intel Arc A770 16GB + 16GB 内存（非主流 AI 配置，下文有对应的坑）。</p>

<h2 id="1-安装-ollama">1. 安装 Ollama</h2>

<p>本文命令与自启示例基于 Linux(systemd)。<strong>Windows/macOS 用户</strong>：Ollama 官方 Windows 版在系统设置里配置环境变量与开机自启，没有 <code>systemctl</code>/<code>journalctl</code>；模型与后续代码章节不受平台影响。macOS 无 A770 对应，代码篇可照跑（Metal 后端自动），细节以<a href="https://docs.ollama.com" target="_blank">官方安装文档</a>为准。</p>

<blockquote>
<p>本教程要求 <strong>Ollama 版本 &gt;= 0.3.0</strong>，确保原生支持 OpenAI 兼容的 <code>/v1/chat/completions</code> 接口（旧版本默认不开启，代码会报 404/400）。</p>

<p>⚠️ 注意：这个 <code>/v1</code> 接口是 <strong>OpenAI 的「近似兼容」实现，不是逐字段等同</strong>——官方兼容文档未列入 <code>tool_choice</code>、<code>n</code>、<code>logit_bias</code> 等支持字段（<code>tool_choice</code> 的部分行为与版本有关，见第 2 篇第 1 节）。具体差异与迁移前自测清单见第 2 篇开头「接口基调」。</p>
</blockquote>

<p>安装失败基本都是网络问题（下载中断、文件不完整），解决方式就两种：<strong>启动 VPN</strong> 或 <strong>换镜像</strong>。</p>

<p>用镜像安装：</p>

<pre><code class="language-bash">export OLLAMA_MIRROR=&quot;https://ghproxy.cn/https://github.com/ollama/ollama/releases/latest/download&quot;
curl -fsSL https://ollama.com/install.sh | sed &quot;s|https://ollama.com/download|$OLLAMA_MIRROR|g&quot; | sh
</code></pre>

<p>若报 <code>llama-server binary not found</code>（安装文件不完整），删掉重装：</p>

<pre><code class="language-bash">sudo rm -rf /usr/local/lib/ollama
curl -fsSL https://ollama.com/install.sh | sh
</code></pre>

<p>安装完成，验证服务：</p>

<pre><code class="language-bash">curl http://localhost:11434
</code></pre>

<p>安装脚本会提示 <code>No NVIDIA/AMD GPU detected</code>——Intel 显卡不被默认识别，下一步解决。</p>

<hr>

<h2 id="2-启用-intel-arc-a770-gpu-加速">2. 启用 Intel Arc A770 GPU 加速</h2>

<p>Ollama 原生优先 NVIDIA（CUDA）与 AMD（ROCm）。Intel Arc 要走 <strong>Vulkan 后端</strong>。</p>

<h3 id="配置">配置</h3>

<pre><code class="language-bash">sudo systemctl edit ollama
</code></pre>

<p>填入：</p>

<pre><code class="language-ini">[Service]
Environment=&quot;OLLAMA_VULKAN=true&quot;
</code></pre>

<p>保存后重启：</p>

<pre><code class="language-bash">sudo systemctl daemon-reload
sudo systemctl enable ollama   # 开机自启（多数安装脚本已自动 enable，此命令幂等可放心执行）
sudo systemctl restart ollama
</code></pre>

<blockquote>
<p>只此一个变量。网上流传的 <code>OLLAMA_INTEL_GPU</code>、<code>OLLAMA_NUM_GPU_LAYERS</code> 等并未出现在 Ollama 官方支持列表里（不同版本支持情况可能变化，可以 <code>ollama serve --help</code> 输出的环境变量清单为准）。注意：<code>OLLAMA_VULKAN</code> 在部分版本（含本环境 0.33.3）不会出现在这份清单里，但该变量确实被识别生效——别因为它没被列出就怀疑配置没生效。设了 Vulkan 后 Ollama 会自动把模型全部层加载到 GPU。</p>
</blockquote>

<h3 id="验证">验证</h3>

<pre><code class="language-bash">journalctl -u ollama --no-pager | grep &quot;inference compute&quot; | tail -3
</code></pre>

<p>看到 <code>Vulkan0 ... Intel Arc A770 Graphics</code> 即成功：</p>

<pre><code>inference compute id=0 library=Vulkan name=Vulkan0
  description=&quot;Intel(R) Arc(tm) A770 Graphics (DG2)&quot; type=discrete total=&quot;15.9 GiB&quot;
</code></pre>

<blockquote>
<p>若在 WSL2 且<strong>未开启 systemd</strong>（开启方法见第 5.6 节），或纯容器等非 systemd 环境，直接前台运行 <code>ollama serve</code>，观察终端输出的 <code>inference compute</code> 日志即可，效果相同。</p>

<p>⚠️ <strong>Vulkan 后端有已知稳定性风险：装好能跑 ≠ 能长期稳定跑。</strong> 上游（llama.cpp / Ollama）在部分 Linux 内核 + Mesa（Intel ANV）驱动组合下存在显存记账失步、空闲显存被换出、偶发 OOM 等记录（如 <a href="https://github.com/ollama/ollama/issues/17802" target="_blank">ollama #17802</a>、<a href="https://github.com/ollama/ollama/issues/18272" target="_blank">ollama #18272</a>、<a href="https://github.com/ggml-org/llama.cpp/issues/18946" target="_blank">llama.cpp #18946</a>、<a href="https://github.com/ggml-org/llama.cpp/issues/25646" target="_blank">llama.cpp #25646</a>），并随 Ollama/Mesa 版本持续修复。长期部署建议：</p>

<ul>
<li><strong>别只看加载时的显存占用</strong>（第 4 节表格是静态值），长时间运行要观察显存曲线是否单调上涨或异常回落；</li>
<li>监控手段：<code>journalctl -u ollama</code> 看 OOM/换出日志；Intel 独显可用 <code>intel_gpu_top</code> 或 <code>xpu-smi</code> 观察显存；</li>
<li>相关环境变量（写入 <code>sudo systemctl edit ollama</code>）：<code>OLLAMA_LOAD_TIMEOUT</code>（模型加载停滞多久后放弃，默认 5m）、<code>OLLAMA_KEEP_ALIVE</code>（空闲多久卸载，默认 5m）、<code>OLLAMA_GPU_OVERHEAD</code>（为驱动/其他进程预留显存）；</li>
<li>出问题时按顺序排查：升级 Ollama（连带更新 llama.cpp 后端）→ 更新 Mesa 驱动 → 换 CUDA/ROCm 或纯 CPU 后端对比，定位是驱动问题还是后端问题。</li>
</ul>
</blockquote>

<hr>

<h2 id="3-选模型">3. 选模型</h2>

<p>本教程默认使用 <strong><code>llama3.1:8b</code></strong>：它对工具调用格式的遵循更稳，8B 量化模型也能全量放入 A770 显存。</p>

<pre><code class="language-bash">ollama pull llama3.1:8b
</code></pre>

<blockquote>
<p>为什么不是 <code>qwen2.5-coder</code>？实测中该模型会把工具调用写成纯文本 <code>content</code>——这是<strong>模型遵循度</strong>问题而非模板问题，也与&rdquo;Qwen 不支持工具调用&rdquo;无关；完整实录、结论与模板自查见文末<strong>附录 A</strong>。若用新版 Ollama，请先 <code>ollama show &lt;model&gt; --modelfile</code> 自查模板是否已更新——模板/模型层问题可能随版本迭代修复。</p>

<p>关于内存：8B 模型（Q4 量化约 4.5GB）可全量放入 A770 的 16GB 显存（实测见本篇第 4 节）。模型主体在显存时系统内存占用很小（实测约 300MB），16GB 内存完全够用；若同时跑多个大模型或加大上下文才需要担心内存，报 <code>killed</code> 时先关闭其他大型应用，或换更小的模型（如 <code>qwen2.5:3b</code>）。</p>
</blockquote>

<hr>

<h2 id="4-实测性能-intel-arc-a770-llama3-1-8b">4. 实测性能（Intel Arc A770 + llama3.1:8b）</h2>

<blockquote>
<p>数据来自本机实测（Ollama 0.33.3），通过 <code>journalctl -u ollama</code> 的模型加载与计时日志读取；环境快照（2026-09 实测）：Ubuntu 24.04 LTS · 内核 7.0.0-31-generic · Mesa Vulkan 驱动 25.2.8（intel-media-va-driver 24.1.0）· Arc A770（DG2）——性能与稳定性随内核/Mesa 组合变化，本文数字以此为基线。</p>

<p>⚠️ 下表是<strong>加载后的静态占用</strong>，不等同于长期稳定性：Vulkan 后端在部分内核/Mesa 驱动下有显存记账失步、空闲显存被换出等已知问题，长时间运行应监控显存曲线而非只看这一时刻，方法见第 2 节。</p>
</blockquote>

<h4 id="显存与内存占用">显存与内存占用</h4>

<table>
<thead>
<tr>
<th>项目</th>
<th>实测值</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>GPU 层加载</td>
<td><strong><sup>33</sup>&frasl;<sub>33</sub> 层全量 offload</strong></td>
<td>整个模型都在 A770 上，非 CPU 混合</td>
</tr>

<tr>
<td>模型占显存</td>
<td>~4.4 GB</td>
<td>A770 16GB 显存余量充足</td>
</tr>

<tr>
<td>KV Cache</td>
<td>~512 MB</td>
<td>上下文缓存也放显存</td>
</tr>

<tr>
<td>系统内存占用</td>
<td>~300 MB</td>
<td>模型主体在显存，16GB 内存轻松扛住</td>
</tr>
</tbody>
</table>

<h4 id="推理速度-生成-14-24-个-token-的实测区间">推理速度（生成 14~24 个 token 的实测区间）</h4>

<table>
<thead>
<tr>
<th>指标</th>
<th>实测值</th>
</tr>
</thead>

<tbody>
<tr>
<td>Prompt 处理</td>
<td>~470 ~ 700 tokens/s</td>
</tr>

<tr>
<td>Token 生成（eval）</td>
<td><strong>~41 tokens/s</strong></td>
</tr>
</tbody>
</table>

<blockquote>
<p>对比参考：纯 CPU 跑 8B 模型通常只有个位数到十几 tokens/s，A770 的 Vulkan 加速收益明显。41 tokens/s 对交互式对话够用，属于&rdquo;能正常用&rdquo;的水平，谈不上飞快。</p>

<p>补充：上表速度来自第 2 篇那种几十 token 的小请求场景，实际耗时主要是 prompt 处理而非生成，整体响应在 1 秒内完成。</p>

<p><strong>长上下文与 KV Cache</strong>：上面的性能数据来自短请求。长上下文场景下，KV Cache 会随 token 数线性增长——32K tokens 下占用可达数 GB（A770 16GB 对 8B 模型日常交互够用，但长上下文场景需关注显存余量）。可通过 <code>OLLAMA_CONTEXT_LENGTH</code>（默认 4k/32k/256k 按显存自适应）限制最大上下文长度，避免显存溢出。</p>

<p>📌 <strong>只想跑代码？</strong> 读到这里就够了——直接跳到第 2 篇。第 5 节的运维内容是给&rdquo;准备长期运行&rdquo;的读者看的，以后再回来不迟。</p>
</blockquote>

<hr>

<h2 id="5-长期运维手册-运维向-可先跳过-上线前必读">5. 长期运维手册（运维向 · 可先跳过，上线前必读）</h2>

<blockquote>
<p>本节是<strong>运维向内容</strong>，面向需要长期部署与维护这台机器的读者（SRE/DevOps 视角）：环境变量、监控、Mesa 驱动排查属于运维知识。<strong>初次跟代码的读者可以先跳过</strong>——不影响第 2~5 篇（第 2 篇只用到&rdquo;模型已拉好、服务正常&rdquo;）；但本节是本系列<strong>最独特的稳定性知识</strong>（Vulkan 已知风险、监控与排查不会自己出现在代码里），<strong>准备长期运行/上线前，务必回来精读</strong>。</p>

<p>本节承接第 2 节的已知风险，给出一套可照做的长期运维流程。先说结论：长期稳定运行没有捷径，就是监控指标、出问题按顺序排查、及时升级到官方修复版本。</p>
</blockquote>

<h3 id="5-1-先给症状分级-判断是不是-vulkan-的锅">5.1 先给症状分级（判断是不是 Vulkan 的锅）</h3>

<table>
<thead>
<tr>
<th>症状</th>
<th>更像谁的问题</th>
<th>先看哪</th>
</tr>
</thead>

<tbody>
<tr>
<td>生成变慢/卡顿（tokens/s 下滑）</td>
<td>显存被换出/接近满载</td>
<td>第 5.2 节监控；上游记录见 <a href="https://github.com/ggml-org/llama.cpp/issues/25646" target="_blank">llama.cpp #25646</a></td>
</tr>

<tr>
<td>显存单调上涨、偶发 OOM / <code>Not enough memory</code></td>
<td>Vulkan/驱动显存记账问题</td>
<td>第 5.2 节 + 升级 Ollama/Mesa</td>
</tr>

<tr>
<td>服务直接崩溃退出</td>
<td>多为后端崩溃</td>
<td><code>journalctl -u ollama</code> 崩溃段</td>
</tr>

<tr>
<td>模型加载久/超时</td>
<td>加载停滞（驱动慢、首次编译 shader）</td>
<td>第 5.3 节 <code>OLLAMA_LOAD_TIMEOUT</code></td>
</tr>
</tbody>
</table>

<h3 id="5-2-监控三件套-命令与看什么">5.2 监控三件套（命令与看什么）</h3>

<ul>
<li><strong>journalctl（主）</strong>：<code>journalctl -u ollama -f</code> 实时看；过滤异常用 <code>journalctl -u ollama --no-pager | grep -iE &quot;error|out of memory|failed&quot;</code>，注意模型加载/换出日志与 OOM 段；</li>
<li><strong>intel_gpu_top（辅）</strong>：Debian/Ubuntu 安装 <code>intel-gpu-tools</code> 后运行，观察 VRAM 占用曲线与渲染引擎占用。建议跑一个长任务 30~60 分钟，确认显存占用会随请求回落而不是单调上涨；</li>
<li><strong>备用</strong>：<code>xpu-smi</code>（若装 Intel 工具链）或直接看 Ollama 日志中的 GPU 统计。</li>
</ul>

<blockquote>
<p>Vulkan 下没有 <code>nvidia-smi</code> 那样标准的显存工具，本机以 journalctl 为主、intel_gpu_top 为辅即可。</p>
</blockquote>

<h3 id="5-3-环境变量速查-写入-systemd-override">5.3 环境变量速查（写入 systemd override）</h3>

<pre><code class="language-bash">sudo systemctl edit ollama
</code></pre>

<pre><code class="language-ini">[Service]
Environment=&quot;OLLAMA_KEEP_ALIVE=10m&quot;
Environment=&quot;OLLAMA_LOAD_TIMEOUT=10m&quot;
Environment=&quot;OLLAMA_GPU_OVERHEAD=1073741824&quot;
</code></pre>

<table>
<thead>
<tr>
<th>变量</th>
<th>默认</th>
<th>含义 / 何时调</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>OLLAMA_KEEP_ALIVE</code></td>
<td>5m</td>
<td>模型空闲多久卸载。频繁调用可调大（如 10m）减少重复加载</td>
</tr>

<tr>
<td><code>OLLAMA_LOAD_TIMEOUT</code></td>
<td>5m</td>
<td>模型加载停滞多久后放弃。驱动慢/首次编译着色器久时可调大</td>
</tr>

<tr>
<td><code>OLLAMA_GPU_OVERHEAD</code></td>
<td>0</td>
<td>为驱动/桌面进程预留显存（字节）。显存接近满时预留可避免 OOM</td>
</tr>

<tr>
<td><code>OLLAMA_MAX_LOADED_MODELS</code></td>
<td>每 GPU 1</td>
<td>同显存跑多个模型时控制换入换出</td>
</tr>
</tbody>
</table>

<blockquote>
<p>完整清单以 <code>ollama serve --help</code> 输出的环境变量为准（第 2 节也提过这一点，版本不同支持情况可能变化）；<code>OLLAMA_VULKAN</code> 是否出现在清单里同样随版本而定，见第 2 节。</p>
</blockquote>

<h3 id="5-4-出问题后的排查顺序">5.4 出问题后的排查顺序</h3>

<ol>
<li>先判断是回归还是环境变化：<code>journalctl -u ollama --since today</code> 找最近一次「正常 → 异常」的转折点；</li>
<li><strong>升级 Ollama</strong>（连带更新 llama.cpp 后端）→ 复测。很多 Vulkan 问题在发布说明里标注了修复版本；</li>
<li>仍复现 → 更新 <strong>Mesa / 内核</strong>（Intel ANV 驱动在 Mesa 里）→ 复测；</li>
<li>仍复现 → <strong>换后端对照</strong>：临时设 <code>OLLAMA_VULKAN=false</code> 纯 CPU 跑一次，判断是驱动路径还是后端逻辑；</li>
<li>升级后仍怀疑上游 bug → 带着最小复现（模型、请求、日志）去 <a href="https://github.com/ollama/ollama/issues" target="_blank">ollama/ollama</a> 或 <a href="https://github.com/ggml-org/llama.cpp/issues" target="_blank">llama.cpp</a> 搜同款 issue。</li>
</ol>

<p>怎么查自己的 Mesa 版本：Ubuntu/Debian 用 <code>dpkg -l | grep mesa-vulkan-drivers</code>；想看运行态驱动名可装 <code>vulkan-tools</code> 后 <code>vulkaninfo | grep -i driverName</code>。升级目标怎么定：上游 issue / 发布说明通常会标注“修复于 Mesa X.Y / Ollama vX”，以该版本为升级目标即可，不必盲目追最新。本文不给出“通用最低 Mesa 版本”——稳定性与内核、发行版打包强相关，请以自身环境实测与上游标注为准。</p>

<h3 id="5-5-常规健康检查-发布-开机前跑一遍">5.5 常规健康检查（发布/开机前跑一遍）</h3>

<pre><code class="language-bash">curl -s http://localhost:11434/api/version          # 服务活着
ollama list                                          # 模型都在
journalctl -u ollama --no-pager | grep &quot;inference compute&quot; | tail -1   # 仍是 Vulkan
ollama ps                                            # 空闲 10 分钟后应为空（KEEP_ALIVE 生效）
</code></pre>

<h3 id="5-6-服务管理-自启-关闭与崩溃自愈">5.6 服务管理：自启、关闭与崩溃自愈</h3>

<p>前面的配置都假设服务&rdquo;一直在跑&rdquo;，但<strong>重启电脑后它回不回来？</strong> 先查三件事：</p>

<pre><code class="language-bash">systemctl is-enabled ollama         # enabled = 开机自启已开启
systemctl status ollama             # active (running) + 最近日志
sudo systemctl enable --now ollama  # 没自启就这样开启并立即启动（幂等，多数安装脚本已自动执行）
</code></pre>

<ul>
<li>不需要开机自启时用 <code>sudo systemctl disable ollama</code> 关闭；</li>
<li>官方安装脚本装出的 <code>ollama.service</code> 自带 <code>Restart=always</code>：<strong>进程崩溃会被 systemd 自动拉起</strong>。所以遇到第 5.1 节&rdquo;服务直接崩溃退出&rdquo;时，先 <code>journalctl -u ollama</code> 找根因，别因为&rdquo;它自己又活了&rdquo;就略过；</li>
<li><strong>WSL2 首选：开启 systemd</strong>。新版 WSL2 支持 systemd：在 <code>/etc/wsl.conf</code> 的 <code>[boot]</code> 段写入 <code>systemd=true</code>，随后 <code>wsl --shutdown</code> 并重新进入发行版；之后与本文完全一致（<code>systemctl enable --now ollama</code>、<code>journalctl -u ollama</code> 照用）；</li>
<li><strong>无法用 systemd 的环境（WSL1、容器等）</strong>：用 <code>tmux new -d 'ollama serve'</code>（或 <code>screen</code>）让会话后台保活，需要看日志时 <code>tmux attach</code>；<code>nohup ... &amp;</code> 只适合临时验证，不建议作为常驻方案。</li>
</ul>

<hr>

<h2 id="附录-a-qwen2-5-coder-工具调用实录-为什么默认选-llama3-1">附录 A：qwen2.5-coder 工具调用实录（为什么默认选 llama3.1）</h2>

<blockquote>
<p>本附录解释第 3 节“默认选 llama3.1”的原因；失败输出样例见第 2 篇「运行结果」的对比小节。</p>
</blockquote>

<p>现象：同一份代码换用 <code>qwen2.5-coder:7b</code>（Ollama 0.33.3），模型把工具调用以 JSON 文本写进 <code>content</code>，而不是标准的 <code>tool_calls</code> 字段。这不是 Qwen 不支持 Function Calling——Ollama 能否把 <code>tools</code> 正确解析成 <code>tool_calls</code>，取决于模型的聊天模板与后端配合。实测发现要分两层看：</p>

<ol>
<li><strong>模板层（通常已不是问题）</strong>：官方 <code>qwen2.5-coder:7b</code> 的 TEMPLATE 已自带完整工具调用格式（<code>ollama show qwen2.5-coder:7b --modelfile</code> 可见 <code>&lt;tool_call&gt;</code> 指令段）；</li>
<li><strong>模型层（真正的坑）</strong>：无论走 <code>/v1</code>、原生 <code>/api/chat</code> 还是 <code>temperature=0</code>，该模型都稳定把 JSON 写进 <code>content</code>——属于<strong>模型遵循度</strong>问题（2026 年社区仍有同类报告），改模板无法解决。</li>
</ol>

<p>结论按型号区分：想留用 Qwen，先试 <strong><code>qwen2.5</code>（instruct 版）</strong>或升级新版 Ollama 后重测；想最快跑通就用本教程默认的 <code>llama3.1:8b</code>。</p>

<blockquote>
<p>结论范围：以上实录限定于 <strong>Ollama 0.33.3 + 官方 <code>qwen2.5-coder:7b</code></strong>（<code>temperature=0</code> 亦复现）；其他 Ollama 版本或采样参数（temperature/seed）下表现可能不同，请以自身环境的实测为准。</p>
</blockquote>

<h3 id="自查-模型模板是否带工具格式">自查：模型模板是否带工具格式</h3>

<ul>
<li><code>ollama show &lt;model&gt; --modelfile | grep -n &quot;Tools\|tool_call&quot;</code>：能看到 <code>.Tools</code> / <code>&lt;tool_call&gt;</code> 说明模板本身支持工具调用；</li>
<li>官方库模型的模板随库维护更新：先 <code>ollama pull</code> 拉最新，再 <code>ollama show</code> 对比；</li>
<li>需要自定义模板/参数时用 Modelfile 派生新模型：<code>FROM &lt;原模型&gt;</code> + 覆盖 <code>TEMPLATE</code>、<code>PARAMETER</code>，<code>ollama create</code> 后 API 改用新模型名——注意：模板只决定提示词长什么样，模型是否遵守是另一回事。</li>
</ul>

<hr>

<h2 id="faq-常见问题速查-环境与运维">FAQ：常见问题速查（环境与运维）</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>原因</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>启动服务报 <code>llama-server binary not found</code></td>
<td>安装文件不完整</td>
<td><code>sudo rm -rf /usr/local/lib/ollama</code> 后重跑安装脚本</td>
</tr>

<tr>
<td>端口 <code>11434</code> 被占用</td>
<td>另一个 Ollama 实例在运行</td>
<td><code>ps aux \| grep ollama</code> 找到并停掉旧进程</td>
</tr>

<tr>
<td>重启电脑后 <code>curl localhost:11434</code> 连不上</td>
<td>服务未设为开机自启</td>
<td><code>sudo systemctl enable --now ollama</code>（WSL2/无 systemd 见第 5.6 节）</td>
</tr>

<tr>
<td>日志显示层全部在 CPU（offload 为 0 层）</td>
<td><code>OLLAMA_VULKAN</code> 未设置或未重启服务</td>
<td>按第 2 节配置并 <code>systemctl restart ollama</code></td>
</tr>

<tr>
<td>运行时报 <code>killed</code> 或内存溢出</td>
<td>同时跑多个大模型或上下文设得过大</td>
<td>关闭大型应用，或换更小的模型</td>
</tr>

<tr>
<td>长时间运行显存上涨、OOM 或生成变慢</td>
<td>Vulkan 后端在部分内核 + Mesa 驱动下有显存记账失步/换出问题</td>
<td>升级 Ollama 与 Mesa；<code>journalctl -u ollama</code>、<code>intel_gpu_top</code> 监控；可设 <code>OLLAMA_GPU_OVERHEAD</code>（第 5.3 节）</td>
</tr>

<tr>
<td>模型加载卡住 / 等很久没反应</td>
<td>驱动或后端问题导致加载停滞</td>
<td>设 <code>OLLAMA_LOAD_TIMEOUT</code> 并看日志定位（第 5.3 节）</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="结论">结论</h2>

<ol>
<li><strong>A770 走 Vulkan 可以全量 offload 8B 模型</strong>：33/33 层、~4.4GB 显存、~41 tokens/s，非 NVIDIA 入门成立；</li>
<li>环境篇到此齐活：镜像安装 → Vulkan → 选模型 → 性能实测 → 长期运维手册；</li>
<li><strong>本文是入门教程，不是生产部署模板</strong>：Vulkan 稳定性风险真实存在，升级与监控是常态动作而非可选项（第 2 节风险清单 + 第 5 节手册），其中第 5 节是部署前必读内容。</li>
</ol>

<h3 id="给第-2-篇读者的环境自检清单-全绿再往下读">给第 2 篇读者的环境自检清单（全绿再往下读）</h3>

<ul>
<li>[ ] <code>curl http://localhost:11434</code> 返回正常</li>
<li>[ ] <code>ollama list</code> 里有 <code>llama3.1:8b</code></li>
<li>[ ] <code>journalctl -u ollama | grep &quot;inference compute&quot;</code> 显示 Vulkan + Arc A770</li>
<li>[ ] 知道 <code>OLLAMA_LOAD_TIMEOUT</code>/<code>OLLAMA_KEEP_ALIVE</code>/<code>OLLAMA_GPU_OVERHEAD</code> 存在且在哪里配置</li>
</ul>

<p>下一篇预告：<strong>《最小代码：单工具一轮调用的完整闭环》</strong>——开始写第一个 Go Agent。</p>
]]></content:encoded>
      <description><![CDATA[在 Intel Arc A770 上把 Ollama 装好并长期跑稳：镜像安装、Vulkan 点亮 GPU、模型选型、实测性能，以及上线前必读的长期运维手册（监控、环境变量、排查顺序）。]]></description>
      <category><![CDATA[Agent]]></category>
      <category><![CDATA[LLM]]></category>
      <dc:relation><![CDATA[series:go-agent]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[测试全绿，组件却坏了——jsdom 看不到的视觉与无障碍盲区]]></title>
      <link>https://moongate.top/docs/jsdom-visual-a11y-testing-blindspots</link>
      <guid isPermaLink="true">https://moongate.top/docs/jsdom-visual-a11y-testing-blindspots</guid>
      <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>单测全绿 ≠ 组件正确。jsdom 看不到两件事：<strong>视觉正确性</strong>（<code>opacity</code>）和<strong>无障碍规范</strong>（<code>aria-*</code>）。本篇是《<a href="./vue-teleport-unit-testing-jsdom-pitfalls">Vue 3 Teleport 组件单元测试指南</a>》的续篇。</p>
</blockquote>

<h2 id="背景">背景</h2>

<p>在《<a href="./vue-teleport-unit-testing-jsdom-pitfalls">Vue 3 Teleport 组件单元测试指南</a>》里，我们讲透了<strong>怎么在 jsdom 里写对测试</strong>——挂载、卸载、清理、时序。那篇文章的隐含前提是：「只要测试写对了、全绿，组件就正确」。</p>

<p>这次我们被现实打脸了两次。</p>

<p>组件库单测一路涨到 <strong>497 个、全绿</strong>，覆盖率 95%/86%。可偏偏在真实浏览器里：</p>

<ul>
<li><strong>Tooltip 悬停，提示框根本不显示</strong>；</li>
<li><strong>Select 的下拉，屏幕阅读器读不到当前高亮的选项</strong>。</li>
</ul>

<p>更讽刺的是，这两个问题<strong>单测都「通过」</strong>——它们不是测试写错了，而是<strong>jsdom 根本没有能力看到这两类错误</strong>：它不渲染像素（视觉），也不实现 ARIA 语义（规范）。这两类盲区，必须靠真浏览器/规范核对来补。</p>

<hr>

<h2 id="盲区一-视觉正确性-opacity-0-的隐形组件">盲区一：视觉正确性——<code>opacity: 0</code> 的隐形组件</h2>

<h3 id="表现">表现</h3>

<p>文档站（VitePress）里，鼠标悬停到 Tooltip 触发区，什么都不显示。</p>

<h3 id="排查">排查</h3>

<p>模板里浮层确实会挂载：</p>

<pre><code class="language-vue">&lt;Teleport to=&quot;body&quot;&gt;
  &lt;div
    v-if=&quot;visible&quot;
    class=&quot;mg-tooltip&quot;
    :class=&quot;`mg-tooltip-${placement}`&quot;
    role=&quot;tooltip&quot;
  &gt;
    {{ content }}
  &lt;/div&gt;
&lt;/Teleport&gt;
</code></pre>

<p><code>v-if=&quot;visible&quot;</code> 成立时元素在 DOM 里，<code>role=&quot;tooltip&quot;</code> 也在。可就是看不见。</p>

<p>看 CSS 才明白：</p>

<pre><code class="language-css">.mg-tooltip {
  /* ... */
  opacity: 0; /* 默认透明 */
  transition: opacity 150ms ease;
}

.mg-tooltip-visible {
  opacity: 1; /* 需要这个类才可见 */
}
</code></pre>

<p><strong>浮层默认 <code>opacity: 0</code>，要等 <code>.mg-tooltip-visible</code> 才可见。但模板里从头到尾没绑定这个类</strong> —— 于是元素永远挂载、永远透明。</p>

<h3 id="为什么单测抓不到">为什么单测抓不到？</h3>

<p>这是关键。看当时单测的断言：</p>

<pre><code class="language-ts">// Tooltip.test.ts（修复前）
it(&quot;鼠标移入后显示 tooltip&quot;, async () =&gt; {
  await trigger(&quot;mouseenter&quot;)
  const tooltip = document.body.querySelector(&quot;.mg-tooltip&quot;)
  expect(tooltip).not.toBeNull() // ✅ 通过
})
</code></pre>

<p>断言的是「<strong><code>.mg-tooltip</code> 元素存在于 DOM</strong>」。元素确实存在——只是透明的。<strong>jsdom 不渲染 CSS</strong>（没有排版引擎、不计算 <code>getComputedStyle</code> 的视觉结果），所以它既不会告诉你 <code>opacity</code> 是多少，也不会因为元素透明而报错。</p>

<blockquote>
<p><strong>jsdom 的视觉盲区</strong>：jsdom 是「DOM 模拟器」不是「浏览器」。它管 DOM 结构、事件、属性，但<strong>不管像素</strong>。<code>opacity</code>、<code>visibility</code>、<code>z-index</code>、<code>position</code> 这类「视觉正确性」，jsdom 一概测不到。</p>
</blockquote>

<h3 id="修复">修复</h3>

<p>绑定 visible 类，让 <code>opacity</code> 在显示时变为 1：</p>

<pre><code class="language-vue">&lt;div
  v-if=&quot;visible&quot;
  class=&quot;mg-tooltip&quot;
  :class=&quot;[
    `mg-tooltip-${placement}`,
    { 'mg-tooltip-visible': visible }, // 补上：显示时加可见类
  ]&quot;
  role=&quot;tooltip&quot;
&gt;
</code></pre>

<h3 id="补充断言-逻辑状态与视觉验证的边界">补充断言：逻辑状态与视觉验证的边界</h3>

<p>单测无法渲染视觉，它能断言的是 <code>v-if</code> 的<strong>结果</strong>——浮层是否挂载（这是可观察的行为）：</p>

<pre><code class="language-ts">it(&quot;悬停后浮层挂载&quot;, async () =&gt; {
  await trigger(&quot;mouseenter&quot;)
  // 断言可观察行为：浮层确实出现在 DOM 中
  expect(document.body.querySelector(&quot;.mg-tooltip&quot;)).not.toBeNull()
})
</code></pre>

<blockquote>
<p>⚠️ <strong>不要用 <code>classList.contains('mg-tooltip-visible')</code> 来断言可见性</strong>：那是把<strong>实现细节</strong>当测试。CSS 类名属于样式出口，不是行为——将来把 <code>opacity</code> 改成 <code>transform: scale(0)</code>、或改用 <code>&lt;Transition&gt;</code> 换类名，这个断言会无辜挂掉（这也是测试界常说的「测行为而非实现」）。如果要一个可断言的「状态出口」，现代组件库更倾向 <code>data-state=&quot;open&quot;</code> 这类属性，而不是样式类名。</p>
</blockquote>

<h3 id="视觉验证的-三层模型-不是只有-e2e-也不是一个-e2e-全搞定">视觉验证的「三层模型」：不是只有 e2e，也不是一个 e2e 全搞定</h3>

<p>说「视觉只能靠 e2e」太笼统。视觉正确性实际分三个层级，工具和盲区各不相同：</p>

<table>
<thead>
<tr>
<th>层级</th>
<th>验证什么</th>
<th>工具</th>
<th>能防 / 不能防</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>① 逻辑层</strong></td>
<td>状态、DOM 结构</td>
<td>单测（jsdom）</td>
<td>防「没挂载」；测不了像素</td>
</tr>

<tr>
<td><strong>② 计算样式层</strong></td>
<td><code>opacity</code>/<code>visibility</code>/<code>z-index</code>/<code>position</code> / 是否被 <code>overflow</code> 裁切</td>
<td>e2e 断 <code>getComputedStyle</code> / <code>getBoundingClientRect</code></td>
<td>防「属性值错」「元素超视口」；<strong>防不了「被别的元素盖住」</strong>（<code>z-index</code> 比它高时 <code>opacity:1</code> 也看不见）</td>
</tr>

<tr>
<td><strong>③ 像素快照层</strong></td>
<td>最终渲染「看起来对不对」</td>
<td>视觉回归（<code>toHaveScreenshot</code>、Percy、Chromatic）</td>
<td>能防一切（含遮挡、错位）；但脆弱（依赖平台字体/环境），成本高</td>
</tr>
</tbody>
</table>

<ul>
<li>这次 Tooltip 的 bug 属于 <strong>②</strong>：<code>getComputedStyle(tooltip).opacity === '1'</code> 就能抓住。</li>
<li>但同一次排查里 Select 的「被 overflow 容器裁剪」问题，<strong>② 也抓不全</strong>——<code>getComputedStyle</code> 不会告诉你「元素被裁到看不见」，得量 <code>getBoundingClientRect()</code> 是否落在容器可视区，或直接交给 <strong>③</strong>。</li>
<li><strong>③ 是终极兜底，但它可以「驯服」而不是一律避用</strong>：

<ul>
<li><strong>优先组件级快照，而不是页面级 E2E 快照</strong>：用 Chromatic/Storyshots 这类工具<strong>只渲染单个组件</strong>做截图对比——输入可控（固定 props/尺寸/主题），噪声远小于整页截图，是国外一线团队（Spotify、微软等）更推崇的做法；</li>
<li><strong>降低脆弱性</strong>：截图对比时配置<strong>忽略字体装载与抗锯齿差异</strong>（diff 只看结构性的布局/颜色偏差），并把 <strong>Diff threshold（像素差异阈值）</strong> 调到组件可接受的容错值——把「平台字体渲染不同」这类噪音排除在阈值之外；</li>
<li>这样「按关键路径取舍」才真正可落地：不是「能不碰就不碰」，而是「关键场景 + 组件级快照 + 合理阈值」。</li>
</ul></li>
</ul>

<blockquote>
<p>结论修正：不是「e2e 是唯一解」，而是「<strong>② 覆盖常见视觉属性错误，③ 覆盖像素级正确，按需取舍</strong>」。jsdom 连 ② 都做不了，这是它必须靠 e2e 的部分。</p>
</blockquote>

<hr>

<h2 id="盲区二-无障碍规范-aria-activedescendant-绑错元素">盲区二：无障碍规范——<code>aria-activedescendant</code> 绑错元素</h2>

<h3 id="表现-1">表现</h3>

<p>给 Select 补 e2e 键盘测试时，断言 <code>aria-activedescendant</code> 指向当前高亮选项，<strong>一直失败</strong>。</p>

<h3 id="排查-dom-里到底绑在哪">排查：DOM 里到底绑在哪？</h3>

<p>用 Playwright 在真浏览器里直接读 DOM，发现：</p>

<pre><code class="language-json">// 输入框（input）：
{ &quot;inputAriaAttrs&quot;: [] }

// 下拉容器（listbox）：
{ &quot;listboxActDesc&quot;: &quot;v-0-option-0&quot; }
</code></pre>

<p><code>aria-activedescendant</code> 被绑在了 <strong>listbox 容器</strong>上，而不是<strong>获得焦点的 input</strong> 上。</p>

<h3 id="为什么这是错的-焦点代理-focus-delegation">为什么这是错的？——焦点代理（Focus Delegation）</h3>

<p>先看规范结论：WAI-ARIA 的 combobox/listbox 组合中，<code>aria-activedescendant</code> <strong>必须放在获得焦点的元素</strong>（这里是可输入的 <code>&lt;input&gt;</code>）。</p>

<p>但要理解「为什么必须放在 input」，得懂底层机制——<strong>焦点代理（Focus Delegation）</strong>——本质就两点：</p>

<ol>
<li><strong>DOM 焦点只能停在一个元素上</strong>。下拉选择时，焦点宿主是可输入的 <code>&lt;input&gt;</code>（combobox），它才是 <code>document.activeElement</code>；listbox 容器只是「受体」——它定义 option 集合，但没有焦点。</li>
<li><code>aria-activedescendant</code> 是<strong>虚拟焦点代理</strong>：DOM 焦点不动，屏幕阅读器的<strong>虚拟光标</strong>跟随它指向的 option——所以属性必须挂在焦点宿主上，容器挂了等于没挂。</li>
</ol>

<p>对照这个机制，问题就清楚了：</p>

<ul>
<li>绑在 <strong>input</strong> 上 → 屏幕阅读器以 input 为宿主，虚拟光标跟随 option，朗读「当前选项是 xxx」；</li>
<li>绑在 <strong>listbox</strong> 上 → 屏幕阅读器在焦点处找不到「指示当前活动项」的线索，<strong>根本读不到当前选项</strong>。</li>
</ul>

<p>这是典型的「属性存在但位置错误」——<strong>axe 在默认规则集下通常不会报错</strong>（<code>aria-activedescendant</code> 的宿主校验属于实验性/可按规则集启用的组合型规则，默认不强制），只有人工对照规范，或用「断言焦点元素（而非容器）上有该属性」的针对性测试才能发现。</p>

<h3 id="为什么单测抓不到-1">为什么单测抓不到？</h3>

<p>单测断言的是「listbox 上有这个属性」：</p>

<pre><code class="language-ts">// Select.test.ts（修复前）
expect(dropdown.attributes(&quot;aria-activedescendant&quot;)).toBe(
  option0.attributes(&quot;id&quot;),
)
</code></pre>

<p><strong>断言本身是「错」的</strong>——它验证了错误的位置。jsdom 不验证规范，你断言什么它就给什么。所以单测「全绿」，恰恰是因为<strong>测试把错误实现当成了预期</strong>。</p>

<blockquote>
<p><strong>jsdom 的无障碍盲区</strong>：jsdom 不实现 ARIA 语义、不做屏幕阅读器。<code>aria-*</code> 属性的「对错」取决于<strong>是否符合规范</strong>，而规范正确性 jsdom 无法判断——它只反射你写的绑定。</p>
</blockquote>

<h3 id="修复-1">修复</h3>

<p>把 <code>aria-activedescendant</code> 从 listbox 移到 input：</p>

<pre><code class="language-vue">&lt;!-- input 上：规范位置 --&gt;
&lt;input
  :aria-activedescendant=&quot;
    focusedIndex &gt;= 0 ? getOptionId(focusedIndex) : undefined
  &quot;
  @keydown.down.prevent=&quot;moveFocus(1)&quot;
  @keydown.up.prevent=&quot;moveFocus(-1)&quot;
  ...
/&gt;
</code></pre>

<p>并同步修正单测断言位置：</p>

<pre><code class="language-ts">// 修复后：断言在 input 上
expect(input.attributes(&quot;aria-activedescendant&quot;)).toBe(option0.attributes(&quot;id&quot;))
</code></pre>

<h3 id="同一个-select-里的另外两个-规范盲区">同一个 Select 里的另外两个「规范盲区」</h3>

<p>排查 activedescendant 时，e2e 又暴露了两个单测测不到的问题：</p>

<p><strong>① 缺 Home/End 键盘处理</strong>（WAI-ARIA listbox 键盘约定要求支持）：</p>

<pre><code class="language-vue">@keydown.home.prevent=&quot;moveFocusTo(0)&quot;
@keydown.end.prevent=&quot;moveFocusTo(filteredOptions.length - 1)&quot;
</code></pre>

<p><strong>② <code>Tab</code> 无法关闭下拉</strong>——<code>mousedownInside</code> 残留 <code>true</code>，导致 <code>blur</code> 被误判为「点击了选项」而不关闭：</p>

<pre><code class="language-ts">const openDropdown = () =&gt; {
  // ...
  mousedownInside.value = false // 修复：打开时重置，避免残留 true 吞掉 blur
}
</code></pre>

<p>这两个都是<strong>事件时序/规范</strong>问题，jsdom 单测同样无能为力——只有真浏览器触发真实键盘/焦点流才暴露。</p>

<hr>

<h2 id="为什么必须靠-e2e-测试的-分层">为什么必须靠 e2e：测试的「分层」</h2>

<table>
<thead>
<tr>
<th>层</th>
<th>验证什么</th>
<th>工具</th>
<th>盲区</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>逻辑层</strong></td>
<td>状态、事件、props、DOM 结构</td>
<td>Vitest + jsdom</td>
<td>视觉、规范</td>
</tr>

<tr>
<td><strong>表现层（属性）</strong></td>
<td><code>opacity</code>/<code>z-index</code>/<code>position</code>、键盘流、ARIA 属性位置</td>
<td>Playwright + 真浏览器</td>
<td>像素级遮挡/重叠</td>
</tr>

<tr>
<td><strong>表现层（像素）</strong></td>
<td>最终渲染「看起来对不对」</td>
<td>视觉回归（截图对比）</td>
<td>平台差异（脆弱）</td>
</tr>
</tbody>
</table>
<p>单测（逻辑层）快而密，但它<strong>默认你「会写对视觉类绑定的位置」</strong>；一旦你写错（漏绑定、绑错元素），它不会提醒你。</p>

<p>e2e（表现层）慢而少，但它验证的是<strong>真实用户看到、读到</strong>的东西。其中「属性验证」覆盖常见视觉问题（<code>opacity</code>/<code>z-index</code>），「像素快照」覆盖终极正确性（含遮挡）——后者昂贵，按关键路径取舍。</p>

<p><strong>正确的分工</strong>：</p>

<ol>
<li>单测覆盖逻辑与数据流（快、多、兜底回归）；</li>
<li>e2e 覆盖「看得见 + 读得出」——悬停出提示、焦点在选项、屏幕阅读器语义正确；</li>
<li>关键路径再加像素快照，防「属性全对但画面错」。</li>
</ol>

<hr>

<h2 id="小结-给测试补上-看不见-的维度">小结：给测试补上「看不见」的维度</h2>

<table>
<thead>
<tr>
<th>盲区</th>
<th>例子</th>
<th>为什么 jsdom 测不到</th>
<th>真正的解法</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>视觉属性</strong></td>
<td>Tooltip <code>opacity: 0</code>、Select 被 <code>overflow</code> 裁切</td>
<td>jsdom 不渲染 CSS/像素</td>
<td>e2e 断 <code>getComputedStyle</code> / <code>getBoundingClientRect</code></td>
</tr>

<tr>
<td><strong>像素级正确</strong></td>
<td>浮层被 <code>z-index</code> 盖住、布局错位</td>
<td>jsdom 无渲染</td>
<td>视觉回归（截图对比，按关键路径取舍）</td>
</tr>

<tr>
<td><strong>无障碍规范</strong></td>
<td><code>aria-activedescendant</code> 绑错元素（焦点代理位置错）</td>
<td>jsdom 不实现 ARIA 语义</td>
<td>e2e 断言<strong>焦点元素</strong>上的属性 + 规范核对</td>
</tr>

<tr>
<td><strong>键盘/焦点流</strong></td>
<td>Select 缺 Home/End、Tab 关不掉</td>
<td>jsdom 不模拟真实键盘/焦点时序</td>
<td>e2e 真键盘事件</td>
</tr>
</tbody>
</table>
<p>单测帮你确认「<strong>代码按我的意图跑</strong>」，e2e 帮你确认「<strong>用户按我的意图看到</strong>」。二者缺一不可——尤其是组件库这种「一个组件被千百人复用」的场景，视觉与无障碍的盲区会被无限放大。</p>

<hr>

<h2 id="附-可复现的最小示例">附：可复现的最小示例</h2>

<h3 id="tooltip-视觉盲区-jsdom-测不到-opacity">Tooltip 视觉盲区（jsdom 测不到 opacity）</h3>

<pre><code class="language-vue">&lt;!-- MyTooltip.vue --&gt;
&lt;template&gt;
  &lt;div
    class=&quot;tooltip-trigger&quot;
    @mouseenter=&quot;show = true&quot;
    @mouseleave=&quot;show = false&quot;
  &gt;
    悬停我
    &lt;Teleport to=&quot;body&quot;&gt;
      &lt;div v-if=&quot;show&quot; class=&quot;my-tooltip&quot;&gt;提示内容&lt;/div&gt;
    &lt;/Teleport&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
import { ref } from &quot;vue&quot;
const show = ref(false)
&lt;/script&gt;

&lt;style&gt;
.my-tooltip {
  opacity: 0;
} /* ❌ 漏了 .visible 类，元素透明 */
&lt;/style&gt;
</code></pre>

<pre><code class="language-ts">// my-tooltip.test.ts —— jsdom 里全绿，浏览器里看不见
import { mount } from &quot;@vue/test-utils&quot;
import MyTooltip from &quot;./MyTooltip.vue&quot;

it(&quot;悬停显示 tooltip&quot;, async () =&gt; {
  const wrapper = mount(MyTooltip, { attachTo: document.body })
  await wrapper.find(&quot;.tooltip-trigger&quot;).trigger(&quot;mouseenter&quot;)
  // ✅ 元素存在 —— jsdom 断言通过
  expect(document.body.querySelector(&quot;.my-tooltip&quot;)).not.toBeNull()
  // ❌ 但 opacity 是 0，用户根本看不见
})
</code></pre>

<h3 id="aria-activedescendant-绑错元素-规范盲区">aria-activedescendant 绑错元素（规范盲区）</h3>

<pre><code class="language-vue">&lt;!-- 错误：绑在 listbox 容器 --&gt;
&lt;div role=&quot;listbox&quot; :aria-activedescendant=&quot;activeId&quot;&gt;
  &lt;div role=&quot;option&quot; :id=&quot;option0Id&quot;&gt;A&lt;/div&gt;

&lt;!-- 正确：绑在获得焦点的 input --&gt;
&lt;input
  :aria-activedescendant=&quot;activeId&quot;
  role=&quot;combobox&quot;
  aria-expanded=&quot;true&quot;
  aria-controls=&quot;listbox-id&quot;
/&gt;
</code></pre>

<hr>

<h2 id="结语">结语</h2>

<p>这次排查最大的收获不是「修好了两个 bug」，而是重新认识了测试的边界：<strong>jsdom 是极好的逻辑验证器，却是糟糕的「视觉/规范」验证器</strong>。</p>

<p>一个组件库若只有单测，就像给一辆车做了全套电路检测，却没上路试驾——<strong>灯亮没亮、方向盘能不能转向，单测测不出来</strong>。把 e2e 补上，才算是真正「能上路」的组件库。</p>

<p>而这两个 bug 也提醒我们：<strong>测试全绿从来不是终点，只是起点</strong>。真正要问的是——「全绿」的测试，到底验证了什么，又漏掉了什么。</p>
]]></content:encoded>
      <description><![CDATA[单测 497 个全绿，Tooltip 悬停却看不见、Select 的屏幕阅读器读不到当前选项。复盘两次排查：jsdom 不渲染视觉、也测不到无障碍规范，这些盲区要靠 e2e 的计算样式验证、像素快照与规范核对来补。附可复现的最小示例。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[GORM 工程化实战（二）：可靠性与生产化]]></title>
      <link>https://moongate.top/docs/gorm-gin-engineering-reliability</link>
      <guid isPermaLink="true">https://moongate.top/docs/gorm-gin-engineering-reliability</guid>
      <pubDate>Sun, 06 Sep 2026 02:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-统一错误处理-中间件-ok-fail-slog">一、统一错误处理：中间件 + ok/fail + slog</h2>

<p><strong>目标：</strong> 把散落在各 handler 的 <code>c.JSON(status, gin.H{&quot;error&quot;: ...})</code> 样板收拢成「handler 只挂错误、中间件统一翻译」，并用 <code>slog</code> 记录服务端日志，超时错误映射为 504。</p>

<h3 id="1-1-统一响应形状-ok-fail">1.1 统一响应形状 ok/fail</h3>

<p>前五篇的响应有两种：成功裸数据、失败 <code>{&quot;error&quot;: ...}</code>。前端每次都要猜。统一成显式形状。先把系列的<strong>响应形状演进</strong>收在一张表里讲清——按顺序读的读者会看到它是&rdquo;摊开 → 收拢&rdquo;的教学过程；跳读的读者请以最新一篇的契约为准：</p>

<table>
<thead>
<tr>
<th>阶段</th>
<th>篇目</th>
<th>成功形状</th>
<th>错误形状</th>
<th>为什么变</th>
</tr>
</thead>

<tbody>
<tr>
<td>单表入门</td>
<td>入门篇</td>
<td>裸对象 / 裸数组</td>
<td><code>{&quot;error&quot;: string}</code></td>
<td>教学：先看清&rdquo;返回什么就是什么&rdquo;</td>
</tr>

<tr>
<td>分页化</td>
<td>媒体篇（第 3 篇）</td>
<td><code>{items, total, page, pageSize}</code></td>
<td>仍是 <code>{&quot;error&quot;: string}</code></td>
<td>列表需要分页元数据——<strong>破坏性变更</strong>，契约以本篇为准</td>
</tr>

<tr>
<td>多条校验错误</td>
<td>数据工程篇（第 4 篇）</td>
<td>不变</td>
<td>可选新增 <code>{&quot;errors&quot;: []}</code></td>
<td>想一次告诉客户端所有字段问题（新形态，接口自选）</td>
</tr>

<tr>
<td>统一收拢</td>
<td>本篇（第 7 篇）</td>
<td><code>{&quot;ok&quot;: true, &quot;data&quot;: …}</code></td>
<td><code>{&quot;ok&quot;: false, &quot;error&quot;: …}</code></td>
<td>成败显式化 + 收拢样板——<strong>最终契约</strong></td>
</tr>
</tbody>
</table>
<p>于是从本篇起，成功走 <code>ok(...)</code>、失败走 <code>fail(...)</code>：</p>

<pre><code class="language-go">// internal/handler/respond.go
package handler

import (
    &quot;net/http&quot;

    &quot;github.com/gin-gonic/gin&quot;
)

func ok(c *gin.Context, status int, data any) {
    c.JSON(status, gin.H{&quot;ok&quot;: true, &quot;data&quot;: data})
}

func fail(c *gin.Context, status int, msg string) {
    c.JSON(status, gin.H{&quot;ok&quot;: false, &quot;error&quot;: msg})
}
</code></pre>

<h3 id="1-2-handler-只挂错误-不再自己写响应">1.2 handler 只挂错误，不再自己写响应</h3>

<p>分层篇的 handler 已经见到雏形（<code>_ = c.Error(err)</code> 后直接 return）。这里把它变成约定：</p>

<pre><code class="language-go">// handler 里的错误路径，只做一件事：把错误挂到 Gin 上下文
if err := h.svc.DeleteBook(c.Request.Context(), id); err != nil {
    _ = c.Error(err) // 具体状态码与日志由错误中间件统一处理
    return
}
ok(c, http.StatusOK, gin.H{&quot;message&quot;: &quot;删除成功&quot;})
</code></pre>

<h3 id="1-3-错误中间件-翻译-分类-日志">1.3 错误中间件：翻译 + 分类 + 日志</h3>

<p><code>c.Next()</code> 之后统一看挂了多少错误，按类型翻译状态码、写 <code>slog</code>：</p>

<pre><code class="language-go">// internal/handler/middleware.go
package handler

import (
    &quot;context&quot;
    &quot;errors&quot;
    &quot;log/slog&quot;
    &quot;net/http&quot;

    &quot;github.com/gin-gonic/gin&quot;
    &quot;gorm.io/gorm&quot;
)

func errorMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()

        logList := c.Errors.ByType(gin.ErrorTypePrivate)
        if len(logList) == 0 {
            return // 没有挂错（或成功响应），不用兜底
        }

        // 1. 服务端日志：真实原因进日志，不进响应
        for _, e := range logList {
            slog.Error(&quot;request failed&quot;,
                &quot;method&quot;, c.Request.Method,
                &quot;path&quot;, c.Request.URL.Path,
                &quot;err&quot;, e.Err,
            )
        }

        // 2. 状态码分类：404（查无记录）/ 504（超时）/ 其余 500
        status := http.StatusInternalServerError
        switch {
        case errors.Is(logList.Last().Err, gorm.ErrRecordNotFound):
            status = http.StatusNotFound // 404
        case errors.Is(logList.Last().Err, context.DeadlineExceeded):
            status = http.StatusGatewayTimeout // 504：服务端超时到期（requestTimeout）。客户端断开是 context.Canceled，不命中这里 → 500
        }

        c.AbortWithStatusJSON(status, gin.H{&quot;ok&quot;: false, &quot;error&quot;: http.StatusText(status)})
    }
}
</code></pre>

<p>注册：</p>

<pre><code class="language-go">r.Use(gin.Logger(), gin.Recovery())
r.Use(errorMiddleware())
</code></pre>

<blockquote>
<p><strong>错误中间件的两个好处</strong>：一是<strong>状态码语义集中</strong>——<code>errors.Is</code> 的区分逻辑（404 vs 500 vs 504）从每个 handler 收进一处，前五篇正文保持显式检查是为了看清它，工程篇就该收拢；二是<strong>日志与响应分离</strong>——<code>slog</code> 记真实原因，客户端只拿到 <code>&quot;Internal Server Error&quot;</code>，不泄露内部细节（呼应<a href="./gorm-gin-relations">《多表关联实战》</a>的「错误消息为什么统一」注记）。</p>
</blockquote>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -i http://localhost:8080/books/999   # 404：{&quot;ok&quot;:false,&quot;error&quot;:&quot;Not Found&quot;}
curl -i -X DELETE http://localhost:8080/books/1/permanent

# 演示 504（服务端超时）：把 main.go 的 requestTimeout 临时调短（如 200ms），
# 再对 books 造一个慢查询（psql 里执行 SELECT pg_sleep(2); 后再发请求），日志里应有
# request failed + err=context deadline exceeded，响应 504。
# 注意：停掉数据库是&quot;连接拒绝&quot;，走 500 而不是 504；本篇没有产生 502 的路径。
</code></pre>

<blockquote>
<p><strong>slog 是标准库的</strong>（<code>log/slog</code>，Go 1.21+）：结构化日志自带 <code>key=value</code> 输出，工程里不再 <code>fmt.Println</code> 凑数。这也是<a href="./gorm-gin-relations">《多表关联实战》</a>注记里&rdquo;生产环境升级为 slog + 统一错误中间件&rdquo;预告的兑现。</p>
</blockquote>

<hr>

<h2 id="二-请求安全补强-排序白名单与文件头嗅探">二、请求安全补强：排序白名单与文件头嗅探</h2>

<p><strong>目标：</strong> 兑现两处安全预告——GetBooks 接受外部排序字段但必须先过白名单；上传校验从&rdquo;扩展名白名单&rdquo;升级为&rdquo;文件内容嗅探&rdquo;。</p>

<h3 id="2-1-getbooks-排序白名单">2.1 GetBooks 排序白名单</h3>

<p>媒体篇 §2.2 的注释承诺过它（&rdquo;接受外部排序字段需先白名单化&rdquo;，见<a href="./gorm-gin-media-query">《文件与查询增强实战》</a>§2.2）。现在兑现：<code>sort</code> 参数进来，先映射进白名单，查不到的拒绝，绝不直接拼进 <code>Order()</code>：</p>

<pre><code class="language-go">// internal/service/book.go —— 排序字段白名单：key 是外部参数，value 是列名
var bookSortWhitelist = map[string]string{
    &quot;createdAt&quot;: &quot;created_at&quot;,
    &quot;updatedAt&quot;: &quot;updated_at&quot;,
    &quot;price&quot;:     &quot;price&quot;,
    &quot;title&quot;:     &quot;title&quot;,
}

// ListBooks 分页 + 搜索 + 白名单排序
func (s *BookService) ListBooks(ctx context.Context, q, sort, dir string, page, pageSize int) ([]models.Book, int64, error) {
    query := s.repo.Query(ctx, q) // 组装好 Where 的链（见分层篇 repository.List 的原型）

    order := &quot;created_at DESC&quot; // 默认
    if col, ok := bookSortWhitelist[sort]; ok {
        if dir == &quot;asc&quot; {
            order = col + &quot; ASC&quot;
        } else {
            order = col + &quot; DESC&quot;
        }
    }
    return s.repo.ListOrdered(ctx, query, order, page, pageSize)
}
</code></pre>

<pre><code class="language-bash">curl &quot;http://localhost:8080/books?sort=price&amp;dir=asc&amp;page=1&amp;pageSize=10&quot;   # 按价格升序 ✓
curl &quot;http://localhost:8080/books?sort=created_at;DROP%20TABLE%20books--&quot;   # 白名单外 → 回落默认排序，永不进 SQL ✓
</code></pre>

<blockquote>
<p><strong>关键在&rdquo;永不拼接&rdquo;：</strong> 白名单让&rdquo;用户的字面输入&rdquo;与&rdquo;SQL 片段&rdquo;之间永远隔着一张映射表——查不到就直接忽略（用默认排序），而不是报错或猜测。<code>order</code> 变量里除了白名单查出的列名，不可能出现其它字符串。</p>
</blockquote>

<h3 id="2-2-上传文件头嗅探">2.2 上传文件头嗅探</h3>

<p>扩展名谁都能伪造（<code>hack.jpg</code> 里放个 exe）。<code>http.DetectContentType</code> 读文件前 512 字节按内容判定&rdquo;真类型&rdquo;：</p>

<pre><code class="language-go">// internal/repository 同层或独立：上传校验收进 handler 边界
func sniffImage(r io.Reader) (string, error) {
    buf := make([]byte, 512)
    n, _ := r.Read(buf)

    ctype, _, _ := mime.ParseMediaType(http.DetectContentType(buf[:n]))
    switch ctype {
    case &quot;image/jpeg&quot;, &quot;image/png&quot;, &quot;image/webp&quot;:
        return ctype, nil
    default:
        return &quot;&quot;, errors.New(&quot;仅允许 jpg/png/webp 图片&quot;)
    }
}
</code></pre>

<p>把它接进 <code>UploadCover</code> 时有一个<strong>必须处理的细节</strong>：<code>sniffImage</code> 会从流里读走前 512 字节，如果接着把同一个 <code>fh</code> 直接交给保存，落盘文件会<strong>丢掉文件头而损坏</strong>（<code>multipart.File</code> 支持 <code>Seek</code>，保存前要 <code>fh.Seek(0, 0)</code> 拨回开头）。这一步会与扩展名白名单、2MB 上限、<code>Uploader.Save</code> 组合在一起——§3.2 会给出一个完整可编译的 <code>UploadCover</code>（嗅探 → 倒回 → 保存一条龙），这里先记住&rdquo;读了要倒回去&rdquo;这个坑。</p>

<pre><code class="language-bash"># 伪造扩展名的非图片：以前 400 靠后缀，现在 400 靠内容
curl -X POST http://localhost:8080/books/1/cover -F &quot;cover=@/etc/hosts;filename=pic.jpg&quot;
# {&quot;ok&quot;:false,&quot;error&quot;:&quot;仅允许 jpg/png/webp 图片&quot;}
</code></pre>

<blockquote>
<p><strong>两种校验的定位：</strong> 扩展名校验是&rdquo;用户体验层&rdquo;（快、能给出友好提示）；内容嗅探是&rdquo;安全层&rdquo;——它验的是<strong>文件头魔数</strong>，能挡掉&rdquo;改名换后缀&rdquo;的伪造，但并非&rdquo;可解码性&rdquo;保证（PNG 文件头 + 任意尾随数据也能通过）。生产里两层都留；本篇的完整函数同样保留媒体篇的 2MB 大小上限（见 §3.2）。</p>
</blockquote>

<hr>

<h2 id="三-上传器抽象-从磁盘到对象存储">三、上传器抽象：从磁盘到对象存储</h2>

<p><strong>目标：</strong> 兑现&rdquo;对象存储&rdquo;预告——用 <code>Uploader</code> 接口把&rdquo;图片存哪&rdquo;从业务里摘出去，Disk 与 S3 只是两个实现。<strong>接口契约：给 key、返回可展示的 URL/路径</strong>——数据库存的是 <code>Save</code> 的返回值（Disk 下是 <code>/uploads/...</code> 相对地址，S3 下是完整 https URL），字段沿用媒体篇的 <code>cover_path</code>；&rdquo;是否另存原始 key&rdquo;的取舍见 3.3 注记。</p>

<h3 id="3-1-为什么需要接口">3.1 为什么需要接口</h3>

<p>媒体篇的 <code>UploadCover</code> 里 <code>c.SaveUploadedFile(file, filepath.Join(&quot;uploads&quot;, ...))</code> 把存储钉死在本地磁盘。换成 S3 要改 handler——而 handler 不该知道存储细节。抽接口：</p>

<pre><code class="language-go">// internal/storage/uploader.go —— 文件头一次给全（interface + Disk + S3 三段拼成同一个文件）
package storage

import (
    &quot;context&quot;
    &quot;fmt&quot;
    &quot;io&quot;
    &quot;os&quot;
    &quot;path/filepath&quot;
)

type Uploader interface {
    // Save 保存 r 的内容到 key，返回可展示的 URL/路径（Disk：/uploads/key；S3：完整 https URL）
    Save(ctx context.Context, key string, r io.Reader) (string, error)
}
</code></pre>

<p>Disk 实现（沿用现在的目录语义，封装成接口实现）：</p>

<pre><code class="language-go">type DiskUploader struct {
    Dir string // ./uploads
    BaseURL string // /uploads
}

func (d *DiskUploader) Save(ctx context.Context, key string, r io.Reader) (string, error) {
    dst := filepath.Join(d.Dir, key)
    if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
        return &quot;&quot;, err
    }
    f, err := os.Create(dst)
    if err != nil {
        return &quot;&quot;, err
    }
    defer f.Close()
    if _, err := io.Copy(f, r); err != nil {
        return &quot;&quot;, err
    }
    return d.BaseURL + &quot;/&quot; + key, nil
}
</code></pre>

<p>S3 骨架（不接真实 AWS，只留形状与调用点）：</p>

<pre><code class="language-go">type S3Uploader struct {
    Bucket  string
    Region  string
}

func (s *S3Uploader) Save(ctx context.Context, key string, r io.Reader) (string, error) {
    // 构造真实实现时：aws-sdk-go-v2 的 s3.PutObject 到这里
    return fmt.Sprintf(&quot;https://%s.s3.%s.amazonaws.com/%s&quot;, s.Bucket, s.Region, key), nil
}
</code></pre>

<h3 id="3-2-handler-只认接口">3.2 handler 只认接口</h3>

<p><code>UploadCover</code> 改造后只依赖 <code>storage.Uploader</code>——业务与存储解耦。先补齐三处&rdquo;接线&rdquo;（篇 6 的 <code>BookHandler</code> 只有 <code>svc</code> 一个字段）：结构体加 <code>up</code>、构造器变双参；Service 加 <code>SetCover</code>（先验书存在、再更新 <code>cover_path</code>）；repository 加对应的单字段更新。</p>

<pre><code class="language-go">// internal/handler/book.go —— BookHandler 结构体与构造器（对照篇 6 的单参版本）
type BookHandler struct {
    svc *service.BookService
    up  storage.Uploader // 篇 6 只有 svc；本篇补存储抽象（见 3.1）
}

func NewBookHandler(svc *service.BookService, up storage.Uploader) *BookHandler {
    return &amp;BookHandler{svc: svc, up: up}
}
</code></pre>

<pre><code class="language-go">// internal/service/book.go —— BookService 增加 SetCover：先确认书存在（404），再更新封面字段
func (s *BookService) SetCover(ctx context.Context, id uint, url string) error {
    if _, err := s.repo.FirstBook(ctx, id); err != nil {
        return err
    }
    return s.repo.SetCover(ctx, id, url)
}

// internal/repository/book.go —— repo.SetCover 单字段更新（示意）：
//   r.db.WithContext(ctx).Model(&amp;models.Book{Model: gorm.Model{ID: id}}).Update(&quot;cover_path&quot;, url).Error
</code></pre>

<p>完整的新 <code>UploadCover</code>（§2.2 嗅探 + 媒体篇大小上限 + 倒回流头 + 3.1 存储抽象一次合体）：</p>

<pre><code class="language-go">func (h *BookHandler) UploadCover(c *gin.Context) {
    // 0. 解析 id（非法 → 400）
    id, err := strconv.ParseUint(c.Param(&quot;id&quot;), 10, 64)
    if err != nil {
        fail(c, http.StatusBadRequest, &quot;id 非法&quot;)
        return
    }
    // 1. 书必须存在（404 由错误中间件翻译）
    book, err := h.svc.GetBook(c.Request.Context(), uint(id))
    if err != nil {
        _ = c.Error(err)
        return
    }
    // 2. 取文件
    file, err := c.FormFile(&quot;cover&quot;)
    if err != nil {
        fail(c, http.StatusBadRequest, &quot;缺少文件字段 cover&quot;)
        return
    }
    fh, err := file.Open()
    if err != nil {
        fail(c, http.StatusBadRequest, &quot;文件读取失败&quot;)
        return
    }
    defer fh.Close()

    // 3. 内容嗅探：扩展名不可信，按文件头判&quot;真类型&quot;（见 §2.2）
    ctype, err := sniffImage(fh)
    if err != nil {
        fail(c, http.StatusBadRequest, err.Error())
        return
    }
    // 4. 大小上限保留（媒体篇的 2MB 检查）
    if file.Size &gt; 2&lt;&lt;20 {
        fail(c, http.StatusBadRequest, &quot;文件过大（上限 2MB）&quot;)
        return
    }
    // 5. 关键：嗅探已消费前 512 字节——保存前必须把流倒回文件头，否则落盘文件损坏
    if _, err := fh.Seek(0, 0); err != nil {
        _ = c.Error(err)
        return
    }
    // 6. 交给 Uploader 落盘：扩展名由内容给出，不信任用户文件名后缀
    ext := map[string]string{&quot;image/jpeg&quot;: &quot;.jpg&quot;, &quot;image/png&quot;: &quot;.png&quot;, &quot;image/webp&quot;: &quot;.webp&quot;}[ctype]
    key := fmt.Sprintf(&quot;%d_%d%s&quot;, book.ID, time.Now().UnixNano(), ext)
    url, err := h.up.Save(c.Request.Context(), key, fh)
    if err != nil {
        _ = c.Error(err)
        return
    }
    // 7. 入库的是 Save 返回的可展示 URL/路径（不是原始 key），并响应
    if err := h.svc.SetCover(c.Request.Context(), book.ID, url); err != nil {
        _ = c.Error(err)
        return
    }
    ok(c, http.StatusOK, gin.H{&quot;coverUrl&quot;: url})
}
</code></pre>

<p><code>main.go</code> 一行切换存储实现：</p>

<pre><code class="language-go">// up := &amp;storage.S3Uploader{Bucket: &quot;my-books&quot;, Region: &quot;ap-southeast-1&quot;}  // 切 S3 只改这里
up := &amp;storage.DiskUploader{Dir: uploadDir, BaseURL: &quot;/uploads&quot;}
bookH := handler.NewBookHandler(bookSvc, up)
</code></pre>

<blockquote>
<p><strong>命名决策回顾（存 key 还是存 URL？）：</strong> 媒体篇对照表埋过两个候选字段——<code>cover_path</code>（存相对地址/URL）与 <code>cover_key</code>（存对象存储的原始 key）。本篇实现的取舍是：<strong><code>Save</code> 直接返回可展示地址并存入 <code>cover_path</code></strong>（Disk 与 S3 只是 URL 形态不同，列语义都是&rdquo;可展示地址&rdquo;），省掉&rdquo;先存 key、读时再拼 URL&rdquo;的一次换算。只有当你要在服务端对原始 key 做二次操作（迁移、CDN 签名、批量删除）时，才值得另开 <code>cover_key</code> 列存原始 key、响应时再拼——那是把&rdquo;存储语义&rdquo;与&rdquo;展示契约&rdquo;彻底拆开的进阶，本篇点到为止。</p>
</blockquote>

<hr>

<h2 id="四-连接池配置与收尾">四、连接池配置与收尾</h2>

<p><strong>目标：</strong> 兑现 ch10 预告的连接池条目，把 <code>db.InitDB()</code> 补上池参数；顺手回顾超时 504（已在中间件里落地）。</p>

<h3 id="4-1-连接池三行">4.1 连接池三行</h3>

<p><code>gorm.Open</code> 返回的 <code>*gorm.DB</code> 包着 <code>database/sql</code> 连接池，用 <code>db.DB()</code> 拿到底层后配置：</p>

<pre><code class="language-go">// db/db.go —— InitDB 末尾追加（db 是包名，包级变量叫 DB，类型 *gorm.DB）
sqlDB, err := DB.DB() // 取底层 *sql.DB
if err != nil {
    log.Fatal(&quot;获取底层连接失败：&quot;, err)
}
sqlDB.SetMaxOpenConns(50)           // 并发上限：防止数据库被打穿
sqlDB.SetConnMaxLifetime(time.Hour) // 生命上限：连接不能永生（数据库侧一般也有超时）
sqlDB.SetMaxIdleConns(10)           // 空闲上限：池中最多保留 10 条空闲连接
</code></pre>

<blockquote>
<p><strong>三个都是&rdquo;上限&rdquo;，别记成&rdquo;下限&rdquo;：</strong> <code>SetMaxOpenConns</code> 是并发上限（超了排队等连接）；<code>SetConnMaxLifetime</code> 是生命上限（到期换新，规避数据库/中间件层的连接回收问题）；<code>SetMaxIdleConns</code> 是<strong>空闲连接数上限</strong>——database/sql 的空闲连接超过该数会被关闭，它不会主动建连&rdquo;保活&rdquo;。想要&rdquo;预热保温&rdquo;效果，得应用自己在启动时主动发起几个查询，这是另一个话题。</p>
</blockquote>

<h3 id="4-2-本篇收尾-全部承诺兑现">4.2 本篇收尾：全部承诺兑现</h3>

<p>系列七篇至此闭环——把五篇处处的预告逐条对账：</p>

<table>
<thead>
<tr>
<th>承诺</th>
<th>出处</th>
<th>兑现位置</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>BookRepository</code> 接口 + Service + 注入 + <code>internal/</code></td>
<td><a href="./gorm-gin-crud-tutorial">入门篇</a>「学习级结构」注记 / ch10「工程化沉淀」/ <a href="./gorm-gin-dto-batch">dto 篇</a>§四</td>
<td>工程化（一）一</td>
</tr>

<tr>
<td><code>httptest</code> 表驱动测试（+ sqlmock）</td>
<td><a href="./gorm-gin-dto-batch">dto 篇</a>§四 / 入门篇注记</td>
<td>工程化（一）二</td>
</tr>

<tr>
<td>泛型 <code>GetPaginated[T]</code></td>
<td>入门篇 ch10 / <a href="./gorm-gin-dto-batch">dto 篇</a>§四</td>
<td>工程化（一）三</td>
</tr>

<tr>
<td>聚合分页收拢（<code>GetPaginatedScan</code>，选读）</td>
<td><a href="./gorm-gin-media-query">媒体篇</a>§2.3</td>
<td>工程化（一）三（选读注记）</td>
</tr>

<tr>
<td>事务 <code>db.Transaction()</code></td>
<td>入门篇 ch10</td>
<td>工程化（一）一（Service 原子操作）</td>
</tr>

<tr>
<td>超时映射 504</td>
<td>入门篇 ch10</td>
<td>本篇一（错误中间件）</td>
</tr>

<tr>
<td>统一错误处理 / ok-fail / slog</td>
<td><a href="./gorm-gin-relations">多表关联篇</a>「错误消息为什么统一」注记 / 入门篇 ch10</td>
<td>本篇一</td>
</tr>

<tr>
<td>排序白名单</td>
<td><a href="./gorm-gin-media-query">媒体篇</a>§2.2 注释与要点</td>
<td>本篇二</td>
</tr>

<tr>
<td>文件头嗅探</td>
<td><a href="./gorm-gin-media-query">媒体篇</a>§1.3 要点</td>
<td>本篇二</td>
</tr>

<tr>
<td>对象存储抽象</td>
<td><a href="./gorm-gin-media-query">媒体篇</a>§1.3（cover_path/cover_key 对照表）</td>
<td>本篇三</td>
</tr>

<tr>
<td>连接池</td>
<td>入门篇 ch10</td>
<td>本篇四</td>
</tr>
</tbody>
</table>
<p><strong>你现在的项目</strong>：三层架构（book 维度链路端到端迁移，其余按同构模板补齐）+ handler 层表驱动测试 + 统一错误中间件（404/500/504 语义集中 + slog 日志）+ 排序白名单 + 文件头嗅探（含流倒回与 2MB 上限）+ 可切换的存储抽象 + 连接池配置——可以放心上线的骨架齐了。</p>

<p>回头看，前几篇里每一处 <code>WithContext</code>、每一次 <code>errors.Is</code> 的铺垫，最后都汇进了本篇的一张错误中间件和一张兑现映射表——它们的意义，在收拢的那一刻才完全显现。</p>
]]></content:encoded>
      <description><![CDATA[系列第 7 篇（收尾）：统一错误中间件与 ok/fail（slog、超时 504）、排序白名单、文件头嗅探与 Uploader 抽象、连接池；末尾附系列承诺兑现映射表。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:gin-gorm]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GORM 工程化实战（一）：分层、注入与可测性]]></title>
      <link>https://moongate.top/docs/gorm-gin-engineering-layering</link>
      <guid isPermaLink="true">https://moongate.top/docs/gorm-gin-engineering-layering</guid>
      <pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-分层重构-从-handler-直连-db-db-到三层">一、分层重构：从 handler 直连 db.DB 到三层</h2>

<p><strong>目标：</strong> 把五篇的平铺代码重构为 <code>internal/repository</code> + <code>internal/service</code> + <code>internal/handler</code>，handler 不再碰数据库，依赖全部由 <code>main.go</code> 构造注入。</p>

<h3 id="1-1-问题的本质-handler-为什么不可测">1.1 问题的本质：handler 为什么不可测</h3>

<p>回顾入门篇的 <code>GetBook</code>：</p>

<pre><code class="language-go">func GetBook(c *gin.Context) {
    // ...直接调 db.DB.WithContext(...).Preload(...).First(&amp;book, id)
}
</code></pre>

<p>handler 和&rdquo;具体拿到哪条数据&rdquo;绑死了。要测试它，就得让测试环境里有一个真的 PostgreSQL——慢、脆、还得造数据。用户可见的行为（状态码、JSON 形状）测不了，这也是前五篇用 curl 验证的原因。<strong>可测性 = 数据访问可替换</strong>，这是分层的全部动机。</p>

<blockquote>
<p><strong>先撞一次墙（为什么前五篇只能 curl）：</strong> 假设想给入门篇的 <code>GetBook</code> 写单元测试——handler 内部直接 <code>db.DB.First(&amp;book, id)</code>，测试就必须：连上一个真 PostgreSQL、建表、准备好&rdquo;存在 / 不存在 / 数据库故障&rdquo;三种数据，还要保证测试之间互不污染。这已经不是&rdquo;单元测试&rdquo;，是&rdquo;起一套环境再手动演戏&rdquo;；故障分支（连接断开）在测试里几乎模拟不出来。所以前五篇用 curl 验证不是偷懒，而是<strong>当时 handler 没有可替换的数据入口</strong>——正文的下一步就从给数据访问安一个&rdquo;可替换的入口&rdquo;下手。</p>
</blockquote>

<h3 id="1-2-目标包结构">1.2 目标包结构</h3>

<p>项目从平铺演进为（入门篇「学习级结构」注记里早就埋过这个方向）：</p>

<pre><code class="language-text">go-learning/                    # gin-demo
├── main.go                     # 组装容器：构造依赖、注册路由
├── db/db.go
├── models/
├── internal/                   # internal/ 包约束：外部 import 即编译错误
│   ├── repository/             # 数据访问（GORM 的活只在这里出现）
│   │   └── book.go
│   ├── service/                # 业务逻辑（跨表、事务在此）
│   │   └── book.go
│   └── handler/                # HTTP 层（参数、绑定、响应）
│       ├── book.go
│       └── pagination.go
└── seed/books.json
</code></pre>

<p><code>internal/</code> 的语义：包路径含 <code>internal</code> 时，只有其父目录内的代码能 import 它——外部模块一引用就编译报错。它把&rdquo;这是本项目内部实现&rdquo;变成编译期事实，而不是约定。</p>

<h3 id="1-3-bookrepository-接口">1.3 BookRepository 接口</h3>

<p><code>internal/repository/book.go</code>——接口定义&rdquo;数据访问需要什么&rdquo;，实现藏在后面。下面直接给出迁移完成后的<strong>最终接口</strong>；但动手时别一次写全：<strong>第一刀只为实现一条链路立 2~3 个方法</strong>（先从 <code>FindByID</code> 起步），跑通 1.6 的注入与测试后，再按这份清单补齐。先读全量形态，是为了看清&rdquo;数据访问需要什么&rdquo;这件事的完整边界：</p>

<pre><code class="language-go">package repository

import (
    &quot;context&quot;

    &quot;go-learning/models&quot;

    &quot;gorm.io/gorm&quot;
)

// BookRepository 数据访问契约：handler/service 只看接口，不看实现
type BookRepository interface {
    Create(ctx context.Context, book *models.Book) error
    FindByID(ctx context.Context, id uint) (*models.Book, error) // 带 Comments 与 Tags 的完整详情
    Query(ctx context.Context, q string) *gorm.DB                              // 搭好条件的链（篇7 的排序白名单要用）
    ListOrdered(ctx context.Context, query *gorm.DB, order string, page, pageSize int) ([]models.Book, int64, error)
    List(ctx context.Context, q string, page, pageSize int) ([]models.Book, int64, error) // 便捷版：ListOrdered 固定倒序
    UpdateBook(ctx context.Context, book *models.Book, fields map[string]interface{}) error
    SoftDelete(ctx context.Context, id uint) error
    AddTag(ctx context.Context, bookID, tagID uint) error
    RemoveTag(ctx context.Context, bookID, tagID uint) error
}
</code></pre>

<blockquote>
<p><strong>半接口的诚实说明：</strong> <code>Query</code> / <code>ListOrdered</code> 的签名里出现了 <code>*gorm.DB</code>——调用方（Service，以及篇 7 的排序白名单）拿到的仍是一条 GORM 链。这说明&rdquo;接口把 GORM 完全挡住&rdquo;是理想态；现实里为了省样板，常让查询链透出。教学上接受这个妥协（想彻底隔离，要把查询也抽象成自己的 DSL，那是另一层话题），但要清楚：<strong>这个接口挡住的是&rdquo;数据库在哪里、怎么连&rdquo;，不是&rdquo;用 GORM 查&rdquo;</strong>。</p>
</blockquote>

<p>GORM 实现（<code>NewBookRepository</code> 返回接口，调用方永远不需要知道具体类型）：</p>

<pre><code class="language-go">type gormBookRepository struct {
    db *gorm.DB
}

// NewBookRepository 构造 GORM 实现并向上转型为接口
func NewBookRepository(db *gorm.DB) BookRepository {
    return &amp;gormBookRepository{db: db}
}

func (r *gormBookRepository) FindByID(ctx context.Context, id uint) (*models.Book, error) {
    var book models.Book
    if err := r.db.WithContext(ctx).Preload(&quot;Comments&quot;).Preload(&quot;Tags&quot;).First(&amp;book, id).Error; err != nil {
        return nil, err
    }
    return &amp;book, nil
}

func (r *gormBookRepository) Query(ctx context.Context, q string) *gorm.DB {
    query := r.db.WithContext(ctx).Model(&amp;models.Book{})
    if q != &quot;&quot; {
        like := &quot;%&quot; + q + &quot;%&quot;
        query = query.Where(&quot;title ILIKE ? OR author ILIKE ?&quot;, like, like)
    }
    return query
}

func (r *gormBookRepository) ListOrdered(ctx context.Context, query *gorm.DB, order string, page, pageSize int) ([]models.Book, int64, error) {
    var total int64
    if err := query.Session(&amp;gorm.Session{}).Count(&amp;total).Error; err != nil {
        return nil, 0, err
    }
    var books []models.Book
    if err := query.Order(order).Offset((page - 1) * pageSize).Limit(pageSize).Find(&amp;books).Error; err != nil {
        return nil, 0, err
    }
    return books, total, nil
}

// List 便捷版：固定倒序（无排序参数场景直接用）
func (r *gormBookRepository) List(ctx context.Context, q string, page, pageSize int) ([]models.Book, int64, error) {
    return r.ListOrdered(ctx, r.Query(ctx, q), &quot;created_at DESC&quot;, page, pageSize)
}
</code></pre>

<blockquote>
<p><strong>接口该多大？</strong> 上面是<strong>本篇迁移范围</strong>的最终形态——只收&rdquo;书 + 标签&rdquo;维度的数据访问（评论与上传的 handler 本篇还没迁，仍直连 <code>db.DB</code>，篇 7 的 Uploader 抽象会一起收）。教学上按聚合根收拢成少数接口、写法同构；真实项目常按聚合根拆多个接口，原则一样。</p>

<p>另一个口径要提前对齐：<code>AddTag(bookID, tagID uint)</code> 是<strong>按 id</strong> 的底层操作——而路由/前端语义是<strong>按名字</strong>打标签（多对多篇 §3.1）。&rdquo;把名字解析成 id、按名建标签、幂等去重&rdquo;是 Service 的活（见 1.4），接口层只暴露最原子的数据操作。</p>
</blockquote>

<h3 id="1-4-service-层与事务">1.4 Service 层与事务</h3>

<p>Service 放&rdquo;跨表的业务逻辑&rdquo;。前五篇 handler 里最像业务的其实是批量导入（数据文件 → 分批插入），但它没有跨表语义。给 Service 找一个真正的立足点：<strong>建书 + 按名打标签</strong>是两步（标签不存在先建、再写连接表），用 <code>db.Transaction()</code> 包成原子操作——按名标签的 <code>FirstOrCreate</code> 去重语义在<a href="./gorm-gin-tags">多对多篇 §3.1</a>已讲，这里只看事务怎么把它们包成一步。</p>

<blockquote>
<p>⚠️ <strong>这是新增行为，不是纯重构：</strong> 前五篇的 <code>POST /books</code> 只建书；本篇起顺带接收 <code>tags</code> 数组、一步完成&rdquo;建书 + 打标签&rdquo;。教程用&rdquo;给已有接口加一个真实存在的跨表场景&rdquo;来给 Service 找立足点——如果你不想改变接口行为，也可以跳过本节、让 Service 先只做透传，等遇到真事务再回来。这里演示的是&rdquo;当需要事务时，代码该长在哪一层&rdquo;。</p>
</blockquote>

<pre><code class="language-go">package service

import (
    &quot;context&quot;

    &quot;go-learning/models&quot;
    &quot;gorm.io/gorm&quot;
)

// BookService 业务逻辑：依赖接口，不依赖数据库
type BookService struct {
    repo BookRepository
    db   *gorm.DB // 事务天然跨仓库，需要直接拿连接（见下方注记）
}

func NewBookService(repo BookRepository, db *gorm.DB) *BookService {
    return &amp;BookService{repo: repo, db: db}
}

// CreateBookWithTags 一步完成「建书 + 打标签」，任一失败整体回滚
func (s *BookService) CreateBookWithTags(ctx context.Context, book *models.Book, tagNames []string) (*models.Book, error) {
    err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // 1. 建书
        if err := tx.Create(book).Error; err != nil {
            return err
        }
        // 2. 每个标签：不存在则先建（FirstOrCreate），再写连接表
        for _, name := range tagNames {
            var tag models.Tag
            if err := tx.Where(&quot;name = ?&quot;, name).FirstOrCreate(&amp;tag, models.Tag{Name: name}).Error; err != nil {
                return err
            }
            if err := tx.Model(book).Association(&quot;Tags&quot;).Append(&amp;tag); err != nil {
                return err
            }
        }
        return nil
    })
    if err != nil {
        return nil, err
    }
    return book, nil
}
</code></pre>

<blockquote>
<p><strong>事务与分层的诚实取舍：</strong> 事务天然&rdquo;跨仓库&rdquo;——一个事务里要操作书、标签、连接表三处。把事务塞进 repository 接口会立刻让接口爆炸（<code>WithTx(func(repo) error)</code> 的 Unit of Work 风格）。本篇采取务实写法：<code>*gorm.DB</code> 直接注入 Service 用于事务，其余数据访问走 <code>repo</code> 接口。规模大了再演进——这跟前面每篇&rdquo;教学坡度&rdquo;的取舍同构。</p>

<p><strong>那 Service 直接拿 <code>db</code>，Repository 接口还挡什么？</strong></p>

<p>接口挡的是<strong>单条数据操作</strong>——<code>Create</code> / <code>FindByID</code> / <code>SoftDelete</code> 这类&rdquo;一次数据访问&rdquo;可以被替换、可以被 fake，handler / service 的分支逻辑因此可测。</p>

<p>事务是另一回事：它是<strong>跨多条操作的编排</strong>，天然不属于任何单个 repo 方法。常见的摆法有三种：</p>

<ol>
<li><strong>Service 持 <code>*gorm.DB</code> 做事务</strong>（本篇路线）：最直白，代价是事务路径没法用 fake 单测，要靠真库集成验证。</li>
<li><strong>Unit of Work</strong>：<code>repo.Transaction(ctx, func(tx BookRepository) error)</code>，把事务边界收进接口——可测性最好，但接口体积、嵌套与实现成本都涨，适合事务密集的中大型项目。</li>
<li><strong>注入&rdquo;事务执行器&rdquo;</strong>：Service 依赖 <code>TxRunner</code> 接口而非裸 <code>*gorm.DB</code>，测试时替换 runner——是 ① 的可测升级版，接口只多一个方法。</li>
</ol>

<p>本篇选 ① 是<strong>教学优先</strong>：把&rdquo;事务怎么写&rdquo;摊开看清，工程取舍留给真实项目；② ③ 是它的演进方向，不是&rdquo;更正确&rdquo;。</p>

<p>这也是系列没有&rdquo;工程化（三）&rdquo;的原因——七篇闭环，剩下的交给读者在真实规模里补课。</p>

<p><strong>怎么选（三个判据走一遍就有答案）：</strong> ① 事务是不是真的&rdquo;跨表/跨聚合&rdquo;（只操作单行就根本不需要事务）；② 事务路径要不要进自动化测试（要 → 就别停在 ①）；③ 规模信号——repo 接口方法超过 ~10 个，或事务点超过 ~3 处（触及 → 从 ① 升 ③）。小项目与教学停在 ① 完全合理；一旦 ② 或 ③ 命中，尽早切 ③（② 适合事务密集、团队已习惯 Unit of Work 的场景）。</p>
</blockquote>

<h3 id="1-5-handler-只剩三件事">1.5 Handler 只剩三件事</h3>

<p>Handler 现在只做：从 Gin 拿参数、调 Service、写响应。数据从哪来？Service 的事：</p>

<pre><code class="language-go">package handler

import (
    &quot;errors&quot;
    &quot;net/http&quot;
    &quot;strconv&quot;

    &quot;go-learning/internal/service&quot;
    &quot;go-learning/models&quot;

    &quot;github.com/gin-gonic/gin&quot;
    &quot;github.com/go-playground/validator/v10&quot;
)

// createBookInput：在数据工程篇的 DTO 基础上新增 Tags（见 1.4 注记：POST /books 顺带打标签是新增行为）
type createBookInput struct {
    Title  string   `json:&quot;title&quot; binding:&quot;required&quot;`
    Author string   `json:&quot;author&quot; binding:&quot;required&quot;`
    Price  *int     `json:&quot;price&quot; binding:&quot;required,gte=0,lte=1000000&quot;`
    Tags   []string `json:&quot;tags&quot;`
}

// bindBookInput 绑定 + 校验错误翻译：把数据工程篇 §2.1 的逻辑收进一个函数，所有 handler 复用
func bindBookInput(c *gin.Context) (*createBookInput, bool) {
    var input createBookInput
    if err := c.ShouldBindJSON(&amp;input); err != nil {
        var ve validator.ValidationErrors
        if errors.As(err, &amp;ve) &amp;&amp; len(ve) &gt; 0 {
            e := ve[0]
            var msg string
            switch e.Tag() {
            case &quot;required&quot;:
                msg = &quot;缺少必填字段 &quot; + e.Field()
            case &quot;gte&quot;:
                msg = e.Field() + &quot; 不能小于 &quot; + e.Param()
            case &quot;lte&quot;:
                msg = e.Field() + &quot; 不能大于 &quot; + e.Param()
            default:
                msg = &quot;参数不合法&quot;
            }
            c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: msg})
            return nil, false
        }
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
        return nil, false
    }
    return &amp;input, true
}

// BookHandler 只依赖 Service；测试想换假实现就是一行的事
type BookHandler struct {
    svc *service.BookService
}

func NewBookHandler(svc *service.BookService) *BookHandler {
    return &amp;BookHandler{svc: svc}
}

func (h *BookHandler) Create(c *gin.Context) {
    input, bound := bindBookInput(c)
    if !bound {
        return // 400 已由 bindBookInput 写好
    }
    book := models.Book{Title: input.Title, Author: input.Author, Price: *input.Price}
    result, err := h.svc.CreateBookWithTags(c.Request.Context(), &amp;book, input.Tags)
    if err != nil {
        _ = c.Error(err) // handler 不写错误响应——挂上去，交给错误中间件
        return
    }
    c.JSON(http.StatusCreated, result)
}

func (h *BookHandler) GetByID(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param(&quot;id&quot;), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;id 非法&quot;})
        return
    }
    book, err := h.svc.GetBook(c.Request.Context(), uint(id))
    if err != nil {
        _ = c.Error(err) // gorm.ErrRecordNotFound → 404、其余 → 500，由中间件翻译
        return
    }
    c.JSON(http.StatusOK, book)
}
</code></pre>

<blockquote>
<p><strong>为什么 DTO 变了？</strong> 前文若写&rdquo;DTO 与校验规则不动&rdquo;是错的：<code>POST /books</code> 要顺带收 <code>tags</code>，<code>createBookInput</code> 必须新增 <code>Tags []string</code>（1.4 注记已说明这是新增行为）；数据工程篇的校验翻译也收成了 <code>bindBookInput</code>，不会退回通用文案（import 见本片段头部：<code>errors</code> / <code>net/http</code> / <code>strconv</code> 与新增的 <code>validator/v10</code> 一次列全）。绑定/参数类 400 由 handler 就地返回——绑定错误不属于&rdquo;数据错误&rdquo;，不进错误中间件。</p>
</blockquote>

<p><strong>错误怎么变成响应？（最小版错误中间件）</strong> handler 只 <code>c.Error(err)</code> 不写响应——翻译由 handler 包提供的错误中间件完成；<code>gin.Engine</code> 上挂一个它，所有 handler 的错误路径就统一了。这里给最小版（404/500），篇 7 会升级为 ok/fail + <code>slog</code> + 504：</p>

<pre><code class="language-go">// internal/handler/error.go
func errorMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()
        logList := c.Errors.ByType(gin.ErrorTypePrivate)
        if len(logList) == 0 {
            return
        }
        status := http.StatusInternalServerError
        if errors.Is(logList.Last().Err, gorm.ErrRecordNotFound) {
            status = http.StatusNotFound
        }
        c.AbortWithStatusJSON(status, gin.H{&quot;error&quot;: http.StatusText(status)})
    }
}

// ErrorMiddleware main 包注册用（handler 包导出）
func ErrorMiddleware() gin.HandlerFunc { return errorMiddleware() }
</code></pre>

<blockquote>
<p><strong>&ldquo;删了 0 行&rdquo;怎么变成 404？</strong> 前五篇删除类接口的 404 来自 <code>RowsAffected == 0</code>（那不是 error）。收进 error 模型后，约定改为：<strong>repository 实现把&rdquo;0 行&rdquo;转成 <code>gorm.ErrRecordNotFound</code> 返回</strong>（删除/移除不存在 → 中间件 404；真错误 → 500）。这样&rdquo;handler 只挂 error&rdquo;才覆盖得住删除语义，不会出现&rdquo;删不存在的书却返回 200&rdquo;。代价是消息收敛为统一的 <code>Not Found</code>——前文的「图书不存在/评论不存在」等具体文案在中间件模型下不再透出，要区分需自定义错误类型（篇 7 的错误分类会处理）。</p>
</blockquote>

<h3 id="1-6-main-go-构造注入">1.6 main.go：构造注入</h3>

<p>依赖全部在 <code>main.go</code> 组装一次，从上到下&rdquo;注入&rdquo;，handler 永远不知道数据库长什么样：</p>

<pre><code class="language-go">func main() {
    db.InitDB()
    if err := db.DB.AutoMigrate(&amp;models.Book{}, &amp;models.Comment{}, &amp;models.Tag{}); err != nil {
        log.Fatal(&quot;迁移失败：&quot;, err)
    }

    // 组装依赖：db → repository → service → handler
    bookRepo := repository.NewBookRepository(db.DB)
    bookSvc := service.NewBookService(bookRepo, db.DB)
    bookH := handler.NewBookHandler(bookSvc)

    r := gin.Default()
    r.Use(requestTimeout(5 * time.Second))
    r.Use(handler.ErrorMiddleware()) // handler 只挂 c.Error，统一翻译 404/500（见 1.5）
    r.Static(&quot;/uploads&quot;, uploadDir)

    r.POST(&quot;/books&quot;, bookH.Create)
    r.GET(&quot;/books&quot;, bookH.List)
    // ...其余路由同构迁移（Get/Update/Delete/评论/标签/上传）
    r.Run(&quot;:8080&quot;)
}
</code></pre>

<blockquote>
<p><strong>顺序即契约：</strong> 依赖方向永远从上往下——<code>db</code> 最底层、<code>handler</code> 最顶层。谁也不能反过来。想替换实现（比如切到 SQLite 驱动、切到 fake），只在 <code>main.go</code> 的组装行改一行。</p>

<p><strong>跑通点（第一刀完成）：</strong> 到这里你只迁了书维度的两条链路（Create / GetByID）——立刻验证：<code>go build ./...</code> 通过，<code>curl http://localhost:8080/books/1</code> 正常返回，说明注入链路通了。其余端点（Update / Delete / 评论 / 标签 / 按标签查书）与这个骨架<strong>完全同构</strong>：接口加方法 → service 透传 → handler 挂错 → main 注册，逐个补齐即可；<strong>上传与 cover 存储先保持直连 <code>db.DB</code></strong>（篇 7 的 Uploader 会一起收）。</p>
</blockquote>

<h3 id="1-7-其余端点的迁移清单-同构模板">1.7 其余端点的迁移清单（同构模板）</h3>

<p>第一刀示范的是骨架，其余端点逐行补齐即可——每一行都走同一套「接口补方法 → gorm 实现 → service 透传 → handler 挂错 → main 注册」，与上面 Create / GetByID 的差异只在参数与校验：</p>

<table>
<thead>
<tr>
<th>端点（旧直连 handler）</th>
<th>repository 要补的方法</th>
<th>service 方法</th>
<th>handler 改造点</th>
<th>事务？</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>PUT /books/:id</code>（部分更新）</td>
<td><code>FirstBook</code>（存在性→404）+ <code>UpdateStruct</code>（零值不更新，篇 1 语义）</td>
<td><code>UpdateBook</code></td>
<td>parseID + bind</td>
<td>否</td>
</tr>

<tr>
<td><code>DELETE /books/:id</code>（软删）</td>
<td><code>SoftDelete</code>（0 行 → <code>gorm.ErrRecordNotFound</code>）</td>
<td><code>SoftDelete</code></td>
<td>parseID + 挂错</td>
<td>否</td>
</tr>

<tr>
<td><code>DELETE /books/:id/permanent</code>（物理删）</td>
<td><code>HardDelete</code></td>
<td><code>HardDelete</code></td>
<td>parseID + 挂错</td>
<td>建议：先 <code>Select(clause.Associations)</code> 清关联再删（tags 篇 §3.4）</td>
</tr>

<tr>
<td>评论：增 / 分页查 / 删</td>
<td><code>CreateComment</code> / <code>ListComments</code> / <code>DeleteComment</code></td>
<td>同名透传</td>
<td>parseID（<code>cid</code> 同理）</td>
<td>否</td>
</tr>

<tr>
<td>标签：加 / 删 / 按标签查书</td>
<td><code>FirstOrCreateTag</code> + <code>CountBookTag</code> + <code>AddTag</code> / <code>RemoveTag</code> / <code>ListByTag</code></td>
<td>按名解析 + 幂等（§1.3 口径）</td>
<td>parseID / <code>:name</code></td>
<td>否</td>
</tr>

<tr>
<td>上传封面</td>
<td>篇 7 收口（<code>storage.Uploader</code> + <code>SetCover</code>）</td>
<td><code>SetCover</code></td>
<td>见篇 7 完整代码</td>
<td>否</td>
</tr>
</tbody>
</table>

<blockquote>
<p>提示：每个新 handler 的测试照 2.2 的模板补自己的分支表即可（查/删路径可用 fake，事务路径靠真库集成验证）。想对照最终实现，可直接看配套代码库中 <code>internal/{repository,service,handler}</code> 的完整形态（模块路径 <code>go-learning</code>）。</p>
</blockquote>

<hr>

<h2 id="二-可测试性-fake-repository-httptest-表驱动">二、可测试性：fake repository + httptest 表驱动</h2>

<p><strong>目标：</strong> 用内存版 repository 把 database 从测试里摘掉，<code>httptest</code> 起一个真 Gin 路由打请求，表驱动覆盖所有状态码分支，然后 <code>go test</code>。</p>

<h3 id="2-1-fake-repository-接口的回报">2.1 fake repository：接口的回报</h3>

<p><code>internal/handler/book_test.go</code>——接口定义时的许诺在这里兑现：</p>

<pre><code class="language-go">package handler

import (
    &quot;context&quot;
    &quot;errors&quot;
    &quot;net/http&quot;
    &quot;net/http/httptest&quot;
    &quot;strings&quot;
    &quot;testing&quot;

    &quot;go-learning/internal/service&quot;
    &quot;go-learning/models&quot;

    &quot;github.com/gin-gonic/gin&quot;
    &quot;gorm.io/gorm&quot;
)

// fakeBookRepo 内存实现：不碰数据库，就能演完所有分支
type fakeBookRepo struct {
    books     map[uint]*models.Book
    findErr   error
    createErr error
}

func (f *fakeBookRepo) Create(ctx context.Context, book *models.Book) error {
    if f.createErr != nil {
        return f.createErr
    }
    book.ID = uint(len(f.books) + 1)
    f.books[book.ID] = book
    return nil
}

func (f *fakeBookRepo) FindByID(ctx context.Context, id uint) (*models.Book, error) {
    if f.findErr != nil {
        return nil, f.findErr
    }
    if b, ok := f.books[id]; ok {
        return b, nil
    }
    return nil, gorm.ErrRecordNotFound
}
// ...其余方法按需实现；不需要的返回假数据即可
</code></pre>

<blockquote>
<p><strong>fake 与 mock 的差别（一句话）：</strong> fake 是&rdquo;能干活的内存实现&rdquo;（上面这个真能返回数据）；mock 是 sqlmock 那种&rdquo;断言你调用了什么&rdquo;的替身。测 handler 用 fake 最顺手——你在测&rdquo;行为&rdquo;，不是测&rdquo;调用序列&rdquo;。</p>
</blockquote>

<h3 id="2-2-httptest-表驱动">2.2 httptest 表驱动</h3>

<p><code>httptest.NewRecorder</code> + 真 <code>gin.Engine</code> 起路由，请求打进去，断言状态码与响应体：</p>

<pre><code class="language-go">func TestBookHandler_GetByID(t *testing.T) {
    tests := []struct {
        name       string
        repo       *fakeBookRepo
        wantStatus int
        wantBody   string
    }{
        {&quot;找到&quot;, &amp;fakeBookRepo{books: map[uint]*models.Book{1: {Model: gorm.Model{ID: 1}, Title: &quot;Go&quot;}}}, 200, `&quot;title&quot;:&quot;Go&quot;`},
        {&quot;不存在&quot;, &amp;fakeBookRepo{books: map[uint]*models.Book{}}, 404, `&quot;error&quot;`},
        {&quot;查询失败&quot;, &amp;fakeBookRepo{books: map[uint]*models.Book{}, findErr: errors.New(&quot;db down&quot;)}, 500, `&quot;error&quot;`},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            svc := service.NewBookService(tt.repo, nil) // fake 不需要真 db
            h := NewBookHandler(svc)

            r := gin.New()
            r.Use(errorMiddleware()) // 与 main 同款：handler 挂错后由中间件翻译 404/500
            r.GET(&quot;/books/:id&quot;, h.GetByID)

            req := httptest.NewRequest(http.MethodGet, &quot;/books/1&quot;, nil)
            w := httptest.NewRecorder()
            r.ServeHTTP(w, req)

            if w.Code != tt.wantStatus {
                t.Errorf(&quot;status = %d, want %d (body: %s)&quot;, w.Code, tt.wantStatus, w.Body.String())
            }
            if !strings.Contains(w.Body.String(), tt.wantBody) {
                t.Errorf(&quot;body = %s, want contains %s&quot;, w.Body.String(), tt.wantBody)
            }
        })
    }
}
</code></pre>

<p>运行：</p>

<pre><code class="language-bash">go test ./internal/handler/ -v
# === RUN   TestBookHandler_GetByID/找到
# === RUN   TestBookHandler_GetByID/不存在
# === RUN   TestBookHandler_GetByID/查询失败
# --- PASS
</code></pre>

<blockquote>
<p><strong>表驱动的意义：</strong> 一个用例 = 一行数据 + 一行断言。&rdquo;找到 / 不存在 / 查询失败&rdquo;三个分支并排躺着，新增分支就是加一行，不用复制测试函数。<strong>被迁移的每个接口</strong>都能照这个模板补自己的分支表（示例演示了 GetByID，其余同构）。两点边界要诚实：(1) 只有走 <code>repo</code> 接口的方法能被 fake 测（GetByID 这类查/删路径）；走 <code>s.db</code> 事务的方法（如 1.4 的 <code>CreateBookWithTags</code>）在 fake + <code>nil db</code> 下会 panic，它们的事务正确性靠对真库的集成验证；(2) 这里测的是 handler 的行为分支，repository 的 GORM 实现是否发出正确 SQL，用 2.3 的 sqlmock 思路验证（点到为止）。</p>
</blockquote>

<h3 id="2-3-repository-层要测吗-sqlmock-一段话">2.3 repository 层要测吗：sqlmock 一段话</h3>

<p>handler 测完了，GORM 实现本身（<code>gormBookRepository</code> 的 SQL 行为）要不要测？可以，用 go-sqlmock（<code>github.com/DATA-DOG/go-sqlmock</code>）：</p>

<pre><code class="language-go">db, mockSQL, _ := sqlmock.New()
gormDB, _ := gorm.Open(postgres.New(postgres.Config{Conn: db}), &amp;gorm.Config{})

mockSQL.ExpectQuery(`SELECT .* FROM &quot;books&quot; WHERE id = .*`).
    WillReturnRows(sqlmock.NewRows([]string{&quot;id&quot;, &quot;title&quot;}).AddRow(1, &quot;Go&quot;))

repo := NewBookRepository(gormDB)
book, err := repo.FindByID(context.Background(), 1)
</code></pre>

<p>这段的价值与代价并存：它断言的是&rdquo;GORM 发出了预想的 SQL&rdquo;——<strong>而这正是 GORM 替你保证的事</strong>。教学结论：<strong>分层要测的是&rdquo;我们的代码&rdquo;（handler 的分支、service 的业务），GORM 的正确性由 GORM 自己负责</strong>。所以本篇以 fake + httptest 为主戏，sqlmock 点到为止。</p>

<hr>

<h2 id="三-选读-提效封装-泛型-getpaginated-t">三、[选读] 提效封装：泛型 GetPaginated[T]</h2>

<p><strong>目标：</strong> 用泛型把「Count + Order + Offset + Limit + 错误处理」收拢成一个函数，各列表接口只留&rdquo;装参数&rdquo;。</p>

<p>前五篇正文刻意显式地写了分页骨架（那是教学）；工程篇兑现承诺，把它收拢。泛型取数函数放在 <strong>repository 包</strong>（它要拿 <code>*gorm.DB</code> 链；若放 handler，repository 反向依赖 handler 会形成循环）：</p>

<blockquote>
<p><strong>选读：</strong> 本节是语法糖，跳过不影响本篇主线。且它只对&rdquo;纯模型分页&rdquo;生效——<code>GetPaginated[models.Book]</code> 这类直接 <code>Find</code> 进模型的列表；媒体篇那种带 JOIN/<code>commentCount</code> 的 <code>BookListItem</code> 聚合列表需要专门查询结构（<code>Scan</code> 进自定义载体），用不上这个泛型。</p>
</blockquote>

<pre><code class="language-go">// internal/repository/pagination.go —— 泛型取数：Count + Order + Offset + Limit 一次收拢
func GetPaginated[T any](query *gorm.DB, order string, page, pageSize int, dest *[]T) (int64, error) {
    var total int64
    if err := query.Session(&amp;gorm.Session{}).Count(&amp;total).Error; err != nil {
        return 0, err
    }
    if err := query.Order(order).
        Offset((page - 1) * pageSize).
        Limit(pageSize).
        Find(dest).Error; err != nil {
        return 0, err
    }
    return total, nil
}
</code></pre>

<p><code>ListOrdered</code>（见 1.3）的分页三行就可以换成本函数——类型参数让 <code>[]models.Book</code>、<code>[]models.Comment</code> 共用同一份逻辑：</p>

<pre><code class="language-go">// repository.ListOrdered 内部（等效写法）
var books []models.Book
total, err := GetPaginated(query, order, page, pageSize, &amp;books)
</code></pre>

<p><strong>Scan 版：聚合列表同样能收拢。</strong> 媒体篇的&rdquo;每本书带评论数&rdquo;走 <code>Scan</code> 进 <code>BookListItem</code>（自定义载体）而不是 <code>Find</code> 进模型——同一套骨架只需把最后一步从 <code>Find</code> 换成 <code>Scan</code>，聚合列表也能分页：</p>

<pre><code class="language-go">// 同一骨架的 Scan 变体：dest 换成自定义查询载体（BookListItem 等）
func GetPaginatedScan[T any](query *gorm.DB, order string, page, pageSize int, dest *[]T) (int64, error) {
    var total int64
    if err := query.Session(&amp;gorm.Session{}).Count(&amp;total).Error; err != nil {
        return 0, err
    }
    if err := query.Order(order).
        Offset((page - 1) * pageSize).
        Limit(pageSize).
        Scan(dest).Error; err != nil {
        return 0, err
    }
    return total, nil
}
</code></pre>

<p>用法（媒体篇 §2.3 的聚合查询——查询链已带 <code>Select/Joins/Group</code>，这里只补分页）：</p>

<pre><code class="language-go">query := db.DB.WithContext(ctx).Model(&amp;models.Book{}).
    Select(&quot;books.*, COUNT(comments.id) AS comment_count&quot;).
    Joins(&quot;LEFT JOIN comments ON comments.book_id = books.id AND comments.deleted_at IS NULL&quot;).
    Group(&quot;books.id&quot;)
var items []BookListItem
total, err := GetPaginatedScan(query, &quot;books.created_at DESC&quot;, page, pageSize, &amp;items)
</code></pre>

<p>一句话：泛型收的是<strong>分页骨架</strong>（Count / 排序 / Offset / Limit），不是取数方式——纯模型列表用 <code>Find</code> 版（<code>GetPaginated</code>），聚合载体用 <code>Scan</code> 版（<code>GetPaginatedScan</code>），两者只差最后一行。两条都属选读。</p>

<p>响应形状归 <strong>handler</strong> 侧，用 <code>PageResult</code> 统一（列表接口的返回结构）：</p>

<pre><code class="language-go">// internal/handler/pagination.go
type PageResult[T any] struct {
    Items    []T   `json:&quot;items&quot;`
    Total    int64 `json:&quot;total&quot;`
    Page     int   `json:&quot;page&quot;`
    PageSize int   `json:&quot;pageSize&quot;`
}
</code></pre>

<blockquote>
<p><strong>为什么现在才收拢？</strong> 呼应本节开头那句&rdquo;工程篇兑现承诺&rdquo;：泛型把&rdquo;分页骨架&rdquo;收成黑盒函数——它先在正文被显式写了两遍（<a href="./gorm-gin-relations">《多表关联实战》</a> §2.2、<a href="./gorm-gin-media-query">《文件与查询增强实战》</a> §2.2），到工程篇才允许封装；超时中间件也是同一思路（&rdquo;正文手写、工程篇可换一行库&rdquo;）。</p>
</blockquote>

<hr>

<h2 id="本篇小结">本篇小结</h2>

<ul>
<li><strong>新包结构</strong>：<code>main.go</code> + <code>db/</code> + <code>models/</code> + <code>internal/{repository,service,handler}</code>；依赖方向 <code>db → repository → service → handler</code>，<code>main.go</code> 一次性构造注入；</li>
<li><strong>本篇示范的迁移范围</strong>：书维度的 Create / GetByID 两条链路端到端走完（接口 → gorm 实现 → Service → handler → 注入 → 测试）；Update / Delete / 评论 / 标签 / 按标签查书与该骨架<strong>同构</strong>，按同一模板补齐即可；<strong>上传 handler 仍直连 <code>db.DB</code></strong>，等篇 7 的 Uploader 抽象一起收；</li>
<li><strong>你现在的项目</strong>：三层架构 + fake/httptest 表驱动测试（示例覆盖 GetByID 的 <sup>404</sup>&frasl;<sub>500</sub> 分支，模板可复用到每个被迁移接口）、<code>go test ./internal/handler/ -v</code> 通过、分页样板已收拢进泛型 <code>GetPaginated[T]</code>（选读）；</li>
<li>下一篇<a href="./gorm-gin-engineering-reliability">《GORM 工程化实战（二）：可靠性与生产化》</a>：统一错误中间件与 ok/fail 响应（<code>slog</code> 接管日志、超时映射 504）、GetBooks 排序白名单、上传文件头嗅探与对象存储抽象、连接池配置——把前五篇预告的可靠性条目一次结清。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[系列第 6 篇：把直连 db.DB 的五篇代码重构为 Repository / Service / Handler 三层（internal/ + 构造注入），fake repository + httptest 表驱动测试，并用泛型 GetPaginated[T]（选读）收拢分页样板。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:gin-gorm]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GORM 多对多实战：书籍与标签]]></title>
      <link>https://moongate.top/docs/gorm-gin-tags</link>
      <guid isPermaLink="true">https://moongate.top/docs/gorm-gin-tags</guid>
      <pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-模型与迁移-many2many-声明">一、模型与迁移：many2many 声明</h2>

<p><strong>目标：</strong> 用 <code>many2many</code> 声明 <code>Book</code> ↔ <code>Tag</code>，让 <code>AutoMigrate</code> 自动建出 <code>tags</code> 与连接表 <code>book_tags</code>。</p>

<h3 id="1-1-tag-模型">1.1 Tag 模型</h3>

<p>新建 <code>models/tag.go</code>：</p>

<pre><code class="language-go">package models

import &quot;gorm.io/gorm&quot;

type Tag struct {
    gorm.Model
    Name string `json:&quot;name&quot; gorm:&quot;uniqueIndex;not null&quot;`
}
</code></pre>

<ul>
<li><code>Name</code> 带唯一索引：同名标签全局只有一个——这是&rdquo;标签去重&rdquo;的基础（<code>FirstOrCreate</code> 依赖它，见第三节）；</li>
</ul>

<blockquote>
<p><strong>自由标签 vs 受控词表（设计决策）：</strong> 本篇按「用户自定义标签」讲（豆瓣式）——任何人可随手打标签，<code>Name</code> 唯一索引 + <code>FirstOrCreate</code> 自动去重，标签随用随建。若你的产品是官方预置的<strong>受控词表</strong>（分类式），改法很简单：
去掉「按名 <code>FirstOrCreate</code> + 按名筛选」，改为预置标签表 + 前端只传已有的 <code>tagId</code>，handler 先校验标签存在（404）再 <code>Append</code>——关系从「按名找或建」变成「按 id 校验」。词表模式没有去重问题，但灵活性低；自由标签需要用归一化兜底（见 3.1）。</p>
</blockquote>

<h3 id="1-2-book-增加关联字段">1.2 Book 增加关联字段</h3>

<p><code>models/book.go</code> 在 <code>CoverPath</code> 之后追加（<code>Comments</code> 等原有字段保持不变）：</p>

<pre><code class="language-go">Tags []Tag `json:&quot;tags,omitempty&quot; gorm:&quot;many2many:book_tags;&quot;` // 多对多：经连接表 book_tags
</code></pre>

<blockquote>
<p><strong>与一对多的本质区别：</strong> 一对多关系写在<strong>子表</strong>上（<code>comments.book_id</code>）；多对多关系写在<strong>标签声明</strong>上（<code>gorm:&quot;many2many:book_tags;&quot;</code>），连接表本身<strong>不需要写模型</strong>——AutoMigrate 会自动建。你声明的是&rdquo;关系&rdquo;，不是&rdquo;表&rdquo;。</p>
</blockquote>

<h3 id="1-3-迁移与验证">1.3 迁移与验证</h3>

<p><code>main.go</code> 的 <code>AutoMigrate</code> 改为同时建三张表（<code>tags</code> 首次迁移会建表；<code>book_tags</code> 连接表也会自动建）：</p>

<pre><code class="language-go">if err := db.DB.AutoMigrate(&amp;models.Book{}, &amp;models.Comment{}, &amp;models.Tag{}); err != nil {
    log.Fatal(&quot;迁移失败：&quot;, err)
}
</code></pre>

<p><strong>验证：</strong></p>

<pre><code class="language-text">\d book_tags
-- 应看到 book_id、tag_id 两列，主键是 (book_id, tag_id) 复合主键（默认唯一）
</code></pre>

<blockquote>
<p>连接表复合主键 = 同一对 (书, 标签) 最多一行——后面&rdquo;重复追加报错&rdquo;和&rdquo;天然去重&rdquo;都源于它。</p>
</blockquote>

<hr>

<h2 id="二-读取-preload-与按标签筛选">二、读取：Preload 与按标签筛选</h2>

<p><strong>目标：</strong> 读取一侧：<code>Preload</code> 按需带出标签、按标签筛选图书。</p>

<h3 id="2-1-详情接口带标签">2.1 详情接口带标签</h3>

<p>多表关联篇的 <code>GetBook</code> 已经 <code>Preload(&quot;Comments&quot;)</code>，这里链上第二个 <code>Preload</code>：</p>

<pre><code class="language-go">result := db.DB.WithContext(c.Request.Context()).
    Preload(&quot;Comments&quot;).
    Preload(&quot;Tags&quot;).
    First(&amp;book, id)
</code></pre>

<ul>
<li><code>Preload</code> 可以链多个：子查询各自批量取（<code>comments</code> 一条、<code>book_tags ⋈ tags</code> 一条），一次组装，<strong>都不触发 N+1</strong>；</li>
<li><strong>列表不 Preload</strong>：契约同多表关联篇（列表轻、详情重）；<code>json:&quot;tags,omitempty&quot;</code> + 不默认加载。</li>
</ul>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl http://localhost:8080/books/1
# 详情里出现 &quot;tags&quot;:[...]（先给书打上标签再测，见第三节）
</code></pre>

<h3 id="2-2-按标签筛选图书">2.2 按标签筛选图书</h3>

<p>&ldquo;列出所有带『Go』标签的书&rdquo;——连接表 JOIN 两次：</p>

<pre><code class="language-go">// GetBooksByTag 列出带指定标签的图书（GET /tags/:name/books）
func GetBooksByTag(c *gin.Context) {
    tagName := c.Param(&quot;name&quot;)

    var books []models.Book
    if err := db.DB.WithContext(c.Request.Context()).
        Joins(&quot;JOIN book_tags ON book_tags.book_id = books.id&quot;).
        Joins(&quot;JOIN tags ON tags.id = book_tags.tag_id&quot;).
        Where(&quot;tags.name = ?&quot;, tagName).
        Find(&amp;books).Error; err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    c.JSON(http.StatusOK, books)
}
</code></pre>

<blockquote>
<p><strong>路由为什么不是 <code>/books/by-tag</code>？（实战坑）</strong> 入门篇已注册 <code>GET /books/:id</code>，而 Gin 的路由树<strong>不允许同一位置静态段与通配段并存</strong>——再注册 <code>/books/by-tag</code> 会直接 panic（&rdquo;conflicts with existing wildcard&rdquo;）。所以按标签筛选放在 <code>tags</code> 前缀下：<code>GET /tags/:name/books</code>，语义也更 REST。</p>
</blockquote>

<p><strong>路由注册：</strong></p>

<pre><code class="language-go">r.GET(&quot;/tags/:name/books&quot;, handlers.GetBooksByTag)
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl &quot;http://localhost:8080/tags/Go/books&quot;
# 返回带 Go 标签的书（先打标签再测）
</code></pre>

<hr>

<h2 id="三-写入与维护-关联的建立与删除">三、写入与维护：关联的建立与删除</h2>

<p><strong>目标：</strong> 写关系：给书加标签（幂等）、移除、整组替换，以及删除父记录时连接表如何处理。</p>

<h3 id="3-1-给书加标签-先查后插-保证幂等">3.1 给书加标签（先查后插，保证幂等）</h3>

<p><code>FirstOrCreate</code> 保证标签唯一（Name 唯一索引），<code>Association(&quot;Tags&quot;).Append</code> 写连接表：</p>

<pre><code class="language-go">// AddBookTag 给书追加一个标签：标签不存在则先创建，再写连接表（POST /books/:id/tags）
func AddBookTag(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 1. 书必须存在
    var book models.Book
    result := db.DB.WithContext(c.Request.Context()).First(&amp;book, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    // 2. 绑定标签名
    var input struct {
        Name string `json:&quot;name&quot; binding:&quot;required&quot;`
    }
    if err := c.ShouldBindJSON(&amp;input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
        return
    }

    // 3. 标签不存在则先创建（Name 唯一索引保证去重）
    var tag models.Tag
    if err := db.DB.WithContext(c.Request.Context()).
        Where(&quot;name = ?&quot;, input.Name).
        FirstOrCreate(&amp;tag, models.Tag{Name: input.Name}).Error; err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;标签处理失败&quot;})
        return
    }

    // 4. 写连接表：先查后插，保证幂等
    var count int64
    if err := db.DB.WithContext(c.Request.Context()).
        Table(&quot;book_tags&quot;).
        Where(&quot;book_id = ? AND tag_id = ?&quot;, book.ID, tag.ID).
        Count(&amp;count).Error; err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询标签关系失败&quot;})
        return
    }
    if count == 0 {
        if err := db.DB.WithContext(c.Request.Context()).
            Model(&amp;book).Association(&quot;Tags&quot;).Append(&amp;tag); err != nil {
            _ = c.Error(err)
            c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;添加标签失败&quot;})
            return
        }
    }

    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;标签已添加&quot;})
}
</code></pre>

<blockquote>
<p><strong>自由标签的代价：名称归一化。</strong> <code>FirstOrCreate</code> 的「去重」只对<strong>完全相同的字符串</strong>生效——<code>Go</code>、<code>golang</code>、<code>Go</code>（尾随空格）在数据库里是三个标签。
生产要在入库前归一化：<code>strings.ToLower</code> + <code>strings.TrimSpace</code>，必要时再做别名映射（<code>Go</code> → <code>golang</code>）。本篇不展开实现，但要记住：<strong>唯一索引保证的是「字符串唯一」，不是「语义唯一」。</strong></p>

<p><strong>为什么第 4 步要先查后插？</strong> <code>Association(&quot;Tags&quot;).Append(&amp;tag)</code> 是直接往连接表 <code>INSERT</code>。<code>book_tags</code> 的复合主键 <code>(book_id, tag_id)</code> 保证同一对关系最多一行——<strong>重复追加同一标签会撞唯一约束报错，不是幂等</strong>。所以示例先 <code>Count</code> 检查再插。若你的项目接受&rdquo;重复请求报错&rdquo;的语义，省掉第 4 步的 Count 也行——本篇按幂等演示。</p>
</blockquote>

<p><strong>路由注册：</strong></p>

<pre><code class="language-go">r.POST(&quot;/books/:id/tags&quot;, handlers.AddBookTag)
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X POST http://localhost:8080/books/1/tags \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;name&quot;:&quot;Go&quot;}'
# → 200 标签已添加；再执行一次同样命令，仍是 200（幂等，连接表无重复行）
</code></pre>

<h3 id="3-2-移除标签-直接删连接表行">3.2 移除标签（直接删连接表行）</h3>

<p><code>Association(&quot;Tags&quot;).Delete(&amp;tag)</code> 需要先按 ID 拿到 Tag；本例直接操作连接表更直白——删除条件就是复合主键：</p>

<pre><code class="language-go">// RemoveBookTag 移除书的某个标签（DELETE /books/:id/tags/:tid）
func RemoveBookTag(c *gin.Context) {
    id := c.Param(&quot;id&quot;)
    tid := c.Param(&quot;tid&quot;)

    result := db.DB.WithContext(c.Request.Context()).
        Table(&quot;book_tags&quot;).
        Where(&quot;book_id = ? AND tag_id = ?&quot;, id, tid).
        Delete(nil)

    if result.Error != nil {
        _ = c.Error(result.Error)
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;移除标签失败&quot;})
        return
    }
    if result.RowsAffected == 0 {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;标签不存在或不属于此书&quot;})
        return
    }
    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;标签已移除&quot;})
}
</code></pre>

<ul>
<li><code>Table(&quot;book_tags&quot;)...Delete(nil)</code> 直接对连接表执行 <code>DELETE ... WHERE book_id = ? AND tag_id = ?</code>——读、写、删三路都能走&rdquo;连接表即普通表&rdquo;的思路；</li>
<li><code>RowsAffected == 0</code> → 该书没有这个标签 → 404（与评论删除的归属校验同一套逻辑）。</li>
</ul>

<p><strong>路由注册：</strong></p>

<pre><code class="language-go">r.DELETE(&quot;/books/:id/tags/:tid&quot;, handlers.RemoveBookTag)
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X DELETE http://localhost:8080/books/1/tags/1
# → 200；再删同一条 → 404
</code></pre>

<h3 id="3-3-replace-整组替换-vs-增量追加">3.3 Replace：整组替换 vs 增量追加</h3>

<p><code>Append</code> 是<strong>增量</strong>（在已有关系上加），<code>Replace</code> 是<strong>整组替换</strong>（先清空该书的全部标签再写入）：</p>

<pre><code class="language-go">// 编辑页&quot;全量保存标签&quot;场景：前端传整组标签，旧的不在列表里的会被移除
db.DB.WithContext(c.Request.Context()).Model(&amp;book).Association(&quot;Tags&quot;).Replace(&amp;tags)
</code></pre>

<blockquote>
<p><strong>两种语义别混：</strong> 把 <code>Replace</code> 当 <code>Append</code> 用在&rdquo;追加一个标签&rdquo;的接口上，会静默清掉该书其它标签。增删单条用 Append/Delete，整组保存才用 Replace。</p>
</blockquote>

<hr>

<h3 id="3-4-删除父记录时-连接表怎么办">3.4 删除父记录时，连接表怎么办？</h3>

<p><code>DELETE /books/:id</code>（软删除）与 <code>/books/:id/permanent</code>（物理删除）已存在——多对多让两种删除各有一层讲究：</p>

<ul>
<li><strong>软删除</strong>：给 <code>books.deleted_at</code> 打时间戳，连接表行<strong>原样保留</strong>；书的查询/Preload 都看不见它（书本身被过滤）——与多表关联篇的 comments 同理；</li>
<li><strong>物理删除</strong>：GORM 默认<strong>不会清理连接表行</strong>！孤儿 <code>book_tags</code> 行不挡查询（按书过滤时书已不存在），但会占表空间，ID 复用还会串数据。两种解法：</li>
</ul>

<pre><code class="language-go">// 解法一：删除时显式连坐删除关联（Select(clause.Associations)）
db.DB.WithContext(c.Request.Context()).
    Select(clause.Associations).
    Unscoped().Delete(&amp;models.Book{}, id)
</code></pre>

<pre><code class="language-sql">-- 解法二：建表时给连接表加外键约束，让数据库层级联清理
ALTER TABLE book_tags
  ADD CONSTRAINT fk_book_tags_book FOREIGN KEY (book_id) REFERENCES books(id) ON DELETE CASCADE;
</code></pre>

<blockquote>
<p>教学用解法一（不动数据库结构）；生产常两手都做：GORM 显式连坐 + 数据库外键兜底。</p>
</blockquote>

<hr>

<h2 id="四-进阶-带字段的连接表-joinmodel">四、进阶：带字段的连接表（JoinModel）</h2>

<p><strong>目标：</strong> 给&rdquo;关系本身&rdquo;附加字段——带字段的连接表（JoinModel）什么时候用、怎么声明。</p>

<p>默认连接表只有 <code>book_id</code> / <code>tag_id</code> 两列。要给关系附属性（如&rdquo;这本书的『精读』标签排第几&rdquo;&ldquo;何时打上的标签&rdquo;），需要<strong>显式连接模型</strong>：</p>

<pre><code class="language-go">// models/book_tag.go —— 自定义连接表：复合主键 + 附加字段
type BookTag struct {
    BookID    uint      `gorm:&quot;primaryKey&quot;`
    TagID     uint      `gorm:&quot;primaryKey&quot;`
    Position  int       // 排序值：书内标签的手动顺序
    CreatedAt time.Time // 打标时间
}
</code></pre>

<p>两个模型各挂一个 has-many 指向连接模型：</p>

<pre><code class="language-go">type Book struct {
    // ...原有字段
    BookTags []BookTag `gorm:&quot;foreignKey:BookID&quot;`
}

type Tag struct {
    // ...原有字段
    BookTags []BookTag `gorm:&quot;foreignKey:TagID&quot;`
}
</code></pre>

<p>升级后要点（点到为止）：</p>

<ul>
<li>关联不再靠&rdquo;<code>Tags []Tag</code> + many2many 标签&rdquo;，而是<strong>两个 has-many 指向 <code>BookTag</code></strong>——读写要直接操作连接记录（<code>db.Create(&amp;models.BookTag{BookID: 1, TagID: 2, Position: 1})</code>）；</li>
<li><code>Association(&quot;Tags&quot;)</code> 的便捷自动写不再适用；取&rdquo;某本书的标签&rdquo;变成 <code>Preload(&quot;BookTags&quot;)</code> 后自行映射；</li>
<li><strong>默认连接表覆盖 90% 场景</strong>——只有需要给&rdquo;关系本身&rdquo;存字段时才升级 JoinModel（教学顺序：先默认，再按需升级）。</li>
</ul>

<hr>

<h2 id="本篇小结">本篇小结</h2>

<ul>
<li><strong>本篇新增路由：</strong></li>
</ul>

<table>
<thead>
<tr>
<th>方法</th>
<th>路径</th>
<th>handler</th>
</tr>
</thead>

<tbody>
<tr>
<td>POST</td>
<td><code>/books/:id/tags</code></td>
<td>AddBookTag</td>
</tr>

<tr>
<td>DELETE</td>
<td><code>/books/:id/tags/:tid</code></td>
<td>RemoveBookTag</td>
</tr>

<tr>
<td>GET</td>
<td><code>/tags/:name/books</code></td>
<td>GetBooksByTag</td>
</tr>
</tbody>
</table>
<p>（<code>main.go</code> 增补：<code>AutoMigrate</code> 加 <code>&amp;models.Tag{}</code>、上述三条路由。）</p>

<ul>
<li>你现在的项目：<code>books</code> + <code>comments</code> + <code>tags</code> 三表、封面图上传与静态服务、分页搜索列表、评论 CRUD、标签与连接表、批量导入与 DTO 校验；</li>
<li>系列至此覆盖 has-many / many2many 两种关联形态与查询、聚合、工程方法；可选延伸（选读）：<a href="./gorm-gin-engineering-layering">《GORM 工程化实战（一）：分层、注入与可测性》</a>，把一直直连的 <code>db.DB</code> 重构为 <code>BookRepository</code> 接口 + Service 层 + 表驱动测试——分层后的测试与事务，会让这篇的每次 <code>WithContext</code> 都派上用场。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[系列第 5 篇：兑现伏笔给图书加 tags（多对多）——many2many 声明与连接表、Preload、按标签筛选、关联增删，以及删父记录与带字段连接表两个进阶。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:relation><![CDATA[series:gin-gorm]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GORM 数据工程实战：批量导入、请求 DTO 与校验错误翻译]]></title>
      <link>https://moongate.top/docs/gorm-gin-dto-batch</link>
      <guid isPermaLink="true">https://moongate.top/docs/gorm-gin-dto-batch</guid>
      <pubDate>Fri, 04 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-批量导入-从数据文件到数据库">一、批量导入：从数据文件到数据库</h2>

<p><code>CreateInBatches</code> 的真实使用场景是<strong>后台批量导入、数据初始化、测试造数</strong>——不是日常 CRUD。所以教学也用真实形态：数据放在 <code>seed/books.json</code>，接口读文件、解析、分批插入。</p>

<p><code>seed/books.json</code>（项目根目录新建）：</p>

<pre><code class="language-json">[
  {
    &quot;title&quot;: &quot;The Go Programming Language&quot;,
    &quot;author&quot;: &quot;Donovan &amp; Kernighan&quot;,
    &quot;price&quot;: 4990
  },
  { &quot;title&quot;: &quot;Go in Action&quot;, &quot;author&quot;: &quot;William Kennedy&quot;, &quot;price&quot;: 5900 },
  {
    &quot;title&quot;: &quot;Concurrency in Go&quot;,
    &quot;author&quot;: &quot;Katherine Cox-Buday&quot;,
    &quot;price&quot;: 4600
  },
  { &quot;title&quot;: &quot;Cloud Native Go&quot;, &quot;author&quot;: &quot;Matthew Titmus&quot;, &quot;price&quot;: 5200 },
  { &quot;title&quot;: &quot;100 Go Mistakes&quot;, &quot;author&quot;: &quot;Teiva Harsanyi&quot;, &quot;price&quot;: 4800 }
]
</code></pre>

<p><code>handlers/book.go</code> 新增（import 按<strong>文件</strong>增补：需要 <code>encoding/json</code>、<code>os</code>；若函数里用到 <code>fmt.Sprintf</code>，<code>fmt</code> 也必须加在该文件——Go 的 import 是文件级的，cover.go 里有不代表 book.go 能用）：</p>

<pre><code class="language-go">// SeedBooksFromFile 从 seed/books.json 批量导入图书（后台/数据初始化场景）
func SeedBooksFromFile(c *gin.Context) {
    // 1. 读取数据文件
    data, err := os.ReadFile(&quot;seed/books.json&quot;)
    if err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;读取数据文件失败&quot;})
        return
    }

    // 2. JSON 解析到切片（字段由 json 标签对齐；金额单位：分，换算见下方注记）
    var books []models.Book
    if err := json.Unmarshal(data, &amp;books); err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;数据文件格式错误&quot;})
        return
    }
    if len(books) == 0 {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;数据文件为空&quot;})
        return
    }

    // 3. 分批插入（每批 100 条）
    if err := db.DB.WithContext(c.Request.Context()).CreateInBatches(books, 100).Error; err != nil {
        _ = c.Error(err)
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;批量插入失败&quot;})
        return
    }

    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: fmt.Sprintf(&quot;已导入 %d 本书&quot;, len(books))})
}
</code></pre>

<p><code>CreateInBatches(slice, 100)</code> 按每批 100 条分批插入——数据再多也不会是一条巨型 INSERT。</p>

<blockquote>
<p>一个顺带的启示：数据文件的字段名就是模型的 <code>json</code> 标签（<code>title</code> / <code>author</code> / <code>price</code>），所以&rdquo;加载文件&rdquo;本质是<strong>反序列化到模型</strong>——金额在文件里直接写分（<code>4990</code> = 49.90 元），与入门篇的金额约定保持一致。</p>

<p><strong>注意：</strong> <code>os.ReadFile(&quot;seed/books.json&quot;)</code> 是相对当前工作目录的——服务<strong>必须从项目根目录启动</strong>（<code>go run main.go</code>），否则找不到文件。生产环境这类路径应改为可配置。</p>
</blockquote>

<p><strong>路由注册：</strong> <code>main.go</code> 路由区加一行：</p>

<pre><code class="language-go">r.POST(&quot;/books/bulk&quot;, handlers.SeedBooksFromFile)
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X POST http://localhost:8080/books/bulk
# {&quot;message&quot;:&quot;已导入 5 本书&quot;}

# 用文件与查询增强篇的分页/搜索确认真实数据进来了
curl &quot;http://localhost:8080/books?q=Go&amp;pageSize=5&quot;
# items 里应出现《The Go Programming Language》《Go in Action》等
</code></pre>

<blockquote>
<p>⚠️ <strong>批量导入非幂等：</strong> 重复 POST 会重复插入同一批书（代码没有去重/清表逻辑）。它定位是&rdquo;数据初始化/测试造数&rdquo;工具——重复执行前，先想好要不要清空 <code>books</code>（如 <code>TRUNCATE books RESTART IDENTITY</code>）。</p>
</blockquote>

<hr>

<h2 id="二-请求-dto-把-收什么-和-存什么-分开">二、请求 DTO：把「收什么」和「存什么」分开</h2>

<p>多表关联篇创建评论时，<code>input</code>（只有 <code>nickname</code>/<code>content</code>）和 <code>models.Comment</code>（还有 <code>BookID</code>、<code>gorm.Model</code>）已经分离。入门篇的「进阶：加参数校验」也交代过原因——<strong>校验标签放 DTO 而不是模型</strong>，因为模型会被更新接口复用、<code>required</code> 会挡掉部分更新。这里用图书创建把它正式落地，演示得更彻底：</p>

<pre><code class="language-go">// 请求 DTO：只声明接口&quot;愿意收&quot;的字段 + 校验规则。
// Price 用 *int：binding:&quot;required&quot; 对 int 的 0 会判&quot;缺失&quot;
// （validator 把零值视为未提供），指针才能区分&quot;没传&quot;和&quot;传了 0&quot;。
type createBookInput struct {
    Title  string `json:&quot;title&quot; binding:&quot;required&quot;`
    Author string `json:&quot;author&quot; binding:&quot;required&quot;`
    Price  *int   `json:&quot;price&quot; binding:&quot;required,gte=0,lte=1000000&quot;` // 单位：分，必填，0–10000 元
}

// CreateBook 改造后的绑定部分：
var input createBookInput
if err := c.ShouldBindJSON(&amp;input); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
    return
}
book := models.Book{Title: input.Title, Author: input.Author, Price: *input.Price}
</code></pre>

<ul>
<li><code>binding:&quot;required,gte=0,lte=1000000&quot;</code>：价格必填、<code>0 &lt;= price &lt;= 1000000</code>（即 0–10000 元，单位：分），越界直接 400。<strong>为什么 <code>Price</code> 用 <code>*int</code>？</strong> <code>required</code> 对 int 的零值 <code>0</code> 也会判&rdquo;字段缺失&rdquo;，<code>{&quot;price&quot;:0}</code> 会被 400 拦下；改成 <code>*int</code> 后&rdquo;没传&rdquo;是 <code>nil</code>（400）、&rdquo;传了 0&rdquo; 是 <code>&amp;0</code>（通过）——这正是入门篇讲过的指针语义：<strong><code>nil</code> 表示&rdquo;这个字段没出现&rdquo;</strong>；</li>
<li>DTO 的第二个好处：<code>price</code> 传负数、传 <code>&quot;abc&quot;</code> 都进不了模型——<strong>接口边界在 DTO 层收口，而不是模型层</strong>；</li>
<li>为什么入门篇不这么做？因为单表入门时&rdquo;收什么=存什么&rdquo;，先学 GORM 本体；现在进入多表 + 校验阶段，才需要显式分离（这就是本系列反复出现的&rdquo;教学坡度&rdquo;：难点按篇摊开、每篇只上一个台阶——不是前文错了，是刻意留白）。</li>
<li><strong>行为变更提示：</strong> 入门篇进阶版的 <code>Price int</code> + <code>binding:&quot;gte=0&quot;</code> 让缺省 price 也能通过（按 0 创建）；本篇收紧为 <code>*int</code> + <code>required</code>——缺 <code>price</code> 的旧请求（只传 <code>title</code>/<code>author</code>）在入门篇是 201，到这里变成 400。同一接口、不同阶段语义不同，这是教程有意把&rdquo;缺省即成功&rdquo;改成&rdquo;显式必填&rdquo;，并非笔误。</li>
</ul>

<p><strong>测试：</strong></p>

<blockquote>
<p>⚠️ 下面第 2、3 条 curl 断言的<strong>翻译消息</strong>要到 2.1 小节才会出现——本节的 handler 还只返回通用文案「请发送合法的 JSON」（见上方绑定代码）。先在这里理解指针语义，翻译后的输出 2.1 再兑现。</p>
</blockquote>

<pre><code class="language-bash"># 传 0：指针字段放行，201 创建成功
curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;:&quot;x&quot;,&quot;author&quot;:&quot;y&quot;,&quot;price&quot;:0}'          # 201

# 缺 price：required 命中（指针为 nil），400
curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;:&quot;x&quot;,&quot;author&quot;:&quot;y&quot;}'                    # 400：{&quot;error&quot;:&quot;缺少必填字段 Price&quot;}
</code></pre>

<h3 id="2-1-校验错误翻译-从-400-到-有意义的-400">2.1 校验错误翻译：从「400」到「有意义的 400」</h3>

<p><code>ShouldBindJSON</code> 校验失败时返回的错误不是普通字符串，而是 <code>validator.ValidationErrors</code>（Gin 底层用 go-playground/validator）——每个条目都带 <code>.Field()</code>（字段名）、<code>.Tag()</code>（命中的规则：<code>required</code> / <code>gte</code> / <code>lte</code>）、<code>.Param()</code>（规则参数，如 <code>1000000</code>）。把它拆开，就能按字段/规则返回真正有用的消息：</p>

<pre><code class="language-go">// 写法一：只报第一个校验错误（一次说清一个）
if err := c.ShouldBindJSON(&amp;input); err != nil {
    var ve validator.ValidationErrors
    if errors.As(err, &amp;ve) &amp;&amp; len(ve) &gt; 0 { // 解包 validator 错误（经典写法，任何版本都成立）
        e := ve[0] // 既然只取第一个，就不需要循环
        switch e.Tag() {
        case &quot;required&quot;:
            c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;缺少必填字段 &quot; + e.Field()})
        case &quot;gte&quot;:
            c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: e.Field() + &quot; 不能小于 &quot; + e.Param()})
        case &quot;lte&quot;:
            c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: e.Field() + &quot; 不能大于 &quot; + e.Param()})
        default:
            c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;参数不合法&quot;})
        }
        return
    }
    c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;}) // 非校验错误
    return
}
</code></pre>

<pre><code class="language-go">// 写法二：聚合全部校验错误，一次告诉客户端所有字段问题
if err := c.ShouldBindJSON(&amp;input); err != nil {
    var ve validator.ValidationErrors
    if errors.As(err, &amp;ve) &amp;&amp; len(ve) &gt; 0 {
        msgs := make([]string, 0, len(ve))
        for _, e := range ve {
            switch e.Tag() {
            case &quot;required&quot;:
                msgs = append(msgs, &quot;缺少必填字段 &quot;+e.Field())
            case &quot;gte&quot;:
                msgs = append(msgs, e.Field()+&quot; 不能小于 &quot;+e.Param())
            case &quot;lte&quot;:
                msgs = append(msgs, e.Field()+&quot; 不能大于 &quot;+e.Param())
            default:
                msgs = append(msgs, &quot;参数不合法&quot;)
            }
        }
        c.JSON(http.StatusBadRequest, gin.H{&quot;errors&quot;: msgs}) // 循环外统一返回
        return
    }
    c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
    return
}
</code></pre>

<blockquote>
<p><strong>响应形态提示：</strong> <code>{&quot;errors&quot;: [...]}</code>（数组）是系列<strong>第一次</strong>出现的错误响应形态——此前所有接口的错误体都是 <code>{&quot;error&quot;: string}</code>。主线接口按写法一（单条消息）即可，聚合版留给&rdquo;一次想告诉客户端所有字段问题&rdquo;的接口自选；若要暴露多条字段错误，客户端需适配 <code>errors</code> 数组。</p>

<p>import 需增补 <code>github.com/go-playground/validator/v10</code>（gin 的依赖，直接复用；<code>errors</code> 已在 <code>book.go</code> 用到，无需重复引入）；<code>.Field()</code> 返回的是 Go 字段名（<code>Price</code>），要对应前端约定可再映射成小写。</p>

<p><strong>踩坑提示：<code>return</code> 别放在&rdquo;遍历全部&rdquo;的循环里</strong>——那会让循环只执行第一次就返回，根本&rdquo;聚&rdquo;不起来（语义退化成&rdquo;只报第一个&rdquo;，和写法一重复）。写法一用 <code>ve[0]</code> 明示&rdquo;只要第一个&rdquo;；写法二把返回移到循环外，循环才真正遍历完。两种语义选一种，别混成&rdquo;循环里 <code>return</code> 却以为在聚合&rdquo;。</p>

<p><strong>（可选）泛型简写</strong>：较新的 Go 若提供 <code>errors.AsType[validator.ValidationErrors](err)</code>（该 API 属随版本演进的提案级能力，是否可用以你的 Go 版本文档为准），可一行替代 <code>var ve ...; errors.As(err, &amp;ve)</code>；不确定环境版本时，正文的经典 <code>errors.As</code> 写法在任何版本都成立——本篇以经典写法为准。</p>
</blockquote>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;:&quot;x&quot;,&quot;author&quot;:&quot;y&quot;,&quot;price&quot;:-1}'        # 400：{&quot;error&quot;:&quot;Price 不能小于 0&quot;}
curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;:&quot;x&quot;,&quot;author&quot;:&quot;y&quot;,&quot;price&quot;:1000001}'    # 400：{&quot;error&quot;:&quot;Price 不能大于 1000000&quot;}
curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;:&quot;x&quot;,&quot;author&quot;:&quot;y&quot;}'                    # 400：{&quot;error&quot;:&quot;缺少必填字段 Price&quot;}
</code></pre>

<hr>

<h2 id="三-系列一览">三、系列一览</h2>

<table>
<thead>
<tr>
<th>篇</th>
<th>文件</th>
<th>主题</th>
<th>新增路由</th>
</tr>
</thead>

<tbody>
<tr>
<td>1</td>
<td><code>gorm-gin-crud-tutorial.md</code></td>
<td>单表 CRUD、软删除、零值陷阱</td>
<td><code>/books</code> 五件套 + <code>/books/:id/permanent</code>（可选）</td>
</tr>

<tr>
<td>2</td>
<td><code>gorm-gin-relations.md</code></td>
<td>多表关联：评论 + Preload</td>
<td><code>/books/:id/comments</code>、<code>/books/:id/comments/:cid</code></td>
</tr>

<tr>
<td>3</td>
<td><code>gorm-gin-media-query.md</code></td>
<td>上传 / 分页 / 搜索 / 聚合</td>
<td><code>/books/:id/cover</code>、<code>/uploads/*</code> 静态</td>
</tr>

<tr>
<td>4（本篇）</td>
<td><code>gorm-gin-dto-batch.md</code></td>
<td>批量导入 / DTO / 校验翻译</td>
<td><code>/books/bulk</code></td>
</tr>

<tr>
<td>5</td>
<td><code>gorm-gin-tags.md</code></td>
<td>多对多：标签 + 连接表</td>
<td><code>/books/:id/tags</code>、<code>/tags/:name/books</code></td>
</tr>
</tbody>
</table>
<p>完整路由清单（按注册顺序）：</p>

<table>
<thead>
<tr>
<th>方法</th>
<th>路径</th>
<th>handler</th>
<th>所属篇</th>
</tr>
</thead>

<tbody>
<tr>
<td>POST</td>
<td><code>/books</code></td>
<td>CreateBook</td>
<td>1</td>
</tr>

<tr>
<td>GET</td>
<td><code>/books</code></td>
<td>GetBooks（分页+搜索+评论数）</td>
<td>1 → 3 改造</td>
</tr>

<tr>
<td>GET</td>
<td><code>/books/:id</code></td>
<td>GetBook（带 Comments）</td>
<td>1 → 2 改造</td>
</tr>

<tr>
<td>PUT</td>
<td><code>/books/:id</code></td>
<td>UpdateBook</td>
<td>1</td>
</tr>

<tr>
<td>DELETE</td>
<td><code>/books/:id</code></td>
<td>DeleteBook</td>
<td>1</td>
</tr>

<tr>
<td>DELETE</td>
<td><code>/books/:id/permanent</code></td>
<td>DeleteBookPermanently（可选）</td>
<td>1</td>
</tr>

<tr>
<td>POST</td>
<td><code>/books/:id/comments</code></td>
<td>CreateComment</td>
<td>2</td>
</tr>

<tr>
<td>GET</td>
<td><code>/books/:id/comments</code></td>
<td>ListComments</td>
<td>2</td>
</tr>

<tr>
<td>DELETE</td>
<td><code>/books/:id/comments/:cid</code></td>
<td>DeleteComment</td>
<td>2</td>
</tr>

<tr>
<td>POST</td>
<td><code>/books/:id/cover</code></td>
<td>UploadCover</td>
<td>3</td>
</tr>

<tr>
<td>GET</td>
<td><code>/uploads/*</code></td>
<td><code>r.Static</code> 静态服务</td>
<td>3</td>
</tr>

<tr>
<td>POST</td>
<td><code>/books/bulk</code></td>
<td>SeedBooksFromFile</td>
<td>4（本篇）</td>
</tr>

<tr>
<td>POST</td>
<td><code>/books/:id/tags</code></td>
<td>AddBookTag</td>
<td>5</td>
</tr>

<tr>
<td>DELETE</td>
<td><code>/books/:id/tags/:tid</code></td>
<td>RemoveBookTag</td>
<td>5</td>
</tr>

<tr>
<td>GET</td>
<td><code>/tags/:name/books</code></td>
<td>GetBooksByTag</td>
<td>5</td>
</tr>
</tbody>
</table>

<h2 id="四-工程化条目-已在工程化篇落地">四、工程化条目：已在工程化篇落地</h2>

<p>以下条目是前文各处的预告，已分别在<a href="./gorm-gin-engineering-layering">《工程化（一）·分层、注入与可测性》</a>与<a href="./gorm-gin-engineering-reliability">《工程化（二）·可靠性与生产化》</a>落地：</p>

<ul>
<li><strong>分层与测试</strong>：<code>BookRepository</code> 接口 + Service 层 + <code>httptest</code> 表驱动测试（工程化（一）一、二节）；</li>
<li><strong>提效封装</strong>：泛型 <code>GetPaginated[T]</code> 收拢分页与错误聚合（工程化（一）第三节）；</li>
<li><strong>文件校验补强</strong>：文件头嗅探 <code>http.DetectContentType</code>，防伪造扩展名（工程化（二）第二节）；</li>
<li><strong>排序白名单</strong>：外部排序字段白名单化，杜绝 ORDER 注入（工程化（二）第二节）；</li>
<li><strong>对象存储</strong>：<code>Uploader</code> 接口抽象，Disk / S3 可切换，数据库存 key（工程化（二）第三节）。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[系列第 4 篇：从数据文件批量导入（CreateInBatches）、请求 DTO 与模型分离、参数化校验规则、validator 校验错误翻译；末尾附系列一览、路由总表与后续篇目预告。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:relation><![CDATA[series:gin-gorm]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GORM 文件与查询增强实战：封面上传、分页搜索与评论数聚合]]></title>
      <link>https://moongate.top/docs/gorm-gin-media-query</link>
      <guid isPermaLink="true">https://moongate.top/docs/gorm-gin-media-query</guid>
      <pubDate>Thu, 03 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-图片字段-封面上传与静态服务">一、图片字段：封面上传与静态服务</h2>

<p><strong>目标：</strong> 给书加封面图：一个可用的上传接口，图片存磁盘、路径存数据库、静态目录对外可访问。</p>

<h3 id="1-1-先定命名-为什么叫-cover-path-而不是-cover-url">1.1 先定命名：为什么叫 cover_path 而不是 cover_url</h3>

<p>字段名应当反映<strong>存的是什么</strong>：</p>

<table>
<thead>
<tr>
<th>存的是什么</th>
<th>更贴切的名字</th>
</tr>
</thead>

<tbody>
<tr>
<td>完整 URL（<code>http://host/uploads/x.jpg</code>）</td>
<td><code>cover_url</code></td>
</tr>

<tr>
<td><strong>相对路径（<code>/uploads/x.jpg</code>）——本教程方案</strong></td>
<td><strong><code>cover_path</code></strong></td>
</tr>

<tr>
<td>对象存储 key（S3 等）</td>
<td><code>cover_key</code></td>
</tr>
</tbody>
</table>
<p>本教程把图片落在 <code>uploads/</code> 磁盘目录，数据库存<strong>相对路径</strong>（换域名、接 CDN、挪位置都不必改数据）。所以字段名叫 <code>cover_path</code>。前端习惯用 <code>coverUrl</code>？用 Go tag 把&rdquo;内部语义&rdquo;和&rdquo;对外契约&rdquo;解耦——<strong>内部名准确，JSON 名顺手，两不耽误</strong>：</p>

<pre><code class="language-go">CoverPath string `json:&quot;coverUrl&quot; gorm:&quot;column:cover_path&quot;`
</code></pre>

<h3 id="1-2-模型加字段">1.2 模型加字段</h3>

<p><code>models/book.go</code> 在 <code>Price</code> 之后追加（字段标签写法见 1.1，<code>Comments</code> 等原有字段保持不变）：</p>

<pre><code class="language-go">CoverPath string `json:&quot;coverUrl&quot; gorm:&quot;column:cover_path&quot;` // 相对路径，可空
</code></pre>

<p><code>AutoMigrate</code> 会给 <code>books</code> 补一列可空的 <code>cover_path</code>，<strong>已有行不受影响</strong>（空值）。</p>

<h3 id="1-3-上传接口">1.3 上传接口</h3>

<p><code>handlers/cover.go</code>：</p>

<pre><code class="language-go">package handlers

import (
    &quot;errors&quot;
    &quot;fmt&quot;
    &quot;gin-demo/db&quot;
    &quot;gin-demo/models&quot;
    &quot;net/http&quot;
    &quot;path/filepath&quot;
    &quot;strings&quot;
    &quot;time&quot;

    &quot;github.com/gin-gonic/gin&quot;
    &quot;gorm.io/gorm&quot;
)

// UploadCover 上传图书封面：限制类型与大小，存 uploads/，路径写回 cover_path
func UploadCover(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 1. 书必须存在
    var book models.Book
    result := db.DB.WithContext(c.Request.Context()).First(&amp;book, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    // 2. 取文件并校验
    file, err := c.FormFile(&quot;cover&quot;)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;缺少文件字段 cover&quot;})
        return
    }
    ext := strings.ToLower(filepath.Ext(file.Filename))
    switch ext {
    case &quot;.jpg&quot;, &quot;.jpeg&quot;, &quot;.png&quot;, &quot;.webp&quot;:
    default:
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;仅允许 jpg/png/webp&quot;})
        return
    }
    if file.Size &gt; 2&lt;&lt;20 { // 2&lt;&lt;20 字节 = 2MB。注意这是&quot;事后检查&quot;：FormFile 已把整个文件读入，2MB 不是请求级硬上限（教学够用，真正挡大请求要在更外层做限制）
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;文件过大（上限 2MB）&quot;})
        return
    }

    // 3. 落盘：随机文件名，防覆盖、防路径注入
    filename := fmt.Sprintf(&quot;%d_%d%s&quot;, book.ID, time.Now().UnixNano(), ext)
    if err := c.SaveUploadedFile(file, filepath.Join(&quot;uploads&quot;, filename)); err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;保存文件失败&quot;})
        return
    }

    // 4. 路径写回数据库（只更新这一个字段）
    // coverPath 存的是 URL 路径，用正斜杠拼接（为什么别用 filepath.Join 见下方要点）
    coverPath := &quot;/uploads/&quot; + filename
    if err := db.DB.WithContext(c.Request.Context()).
        Model(&amp;book).Update(&quot;cover_path&quot;, coverPath).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;更新图书失败&quot;})
        return
    }

    c.JSON(http.StatusOK, gin.H{&quot;coverUrl&quot;: coverPath})
}
</code></pre>

<p>要点：</p>

<ul>
<li><strong>文件名不信任用户输入</strong>：<code>filepath.Ext</code> 只取扩展名，主名用 <code>book.ID + 时间戳</code> 拼——既防覆盖也防 <code>../../</code> 这类路径注入；</li>
<li><strong>两种路径别混</strong>：<code>coverPath</code> 是 <strong>URL 路径</strong>，永远正斜杠 <code>/</code>（存数据库、给前端），用 <code>path.Join</code> 或直接拼接；落盘才用 <code>filepath.Join</code>（按 OS 分隔符）。Windows 反斜杠恰恰是反例——用 <code>filepath.Join</code> 拼 URL 会在 Windows 下产出 <code>\uploads\</code> 存库，前端永远匹配不上；</li>
<li>类型校验用的是<strong>扩展名白名单</strong>（简单教学版；严格做法要嗅探文件头 <code>http.DetectContentType</code>，落地见<a href="./gorm-gin-engineering-reliability">《GORM 工程化实战（二）：可靠性与生产化》</a>第二节）；</li>
<li><code>Model(&amp;book).Update(&quot;cover_path&quot;, ...)</code> 单字段更新，不会误动其它字段（与入门篇 <code>Updates(struct)</code> 语义呼应）。</li>
</ul>

<h3 id="1-4-静态服务与目录准备">1.4 静态服务与目录准备</h3>

<p><code>main.go</code> 三处小改动：</p>

<pre><code class="language-go">import (
    &quot;os&quot;
    &quot;path/filepath&quot;
    // ...其余不变
)

func main() {
    db.InitDB()
    if err := db.DB.AutoMigrate(&amp;models.Book{}, &amp;models.Comment{}); err != nil {
        log.Fatal(&quot;迁移失败：&quot;, err)
    }

    // 磁盘目录：用 filepath.Join 拼（OS 分隔符），MkdirAll 与 Static 共用同一个值
    uploadDir := filepath.Join(&quot;.&quot;, &quot;uploads&quot;)
    _ = os.MkdirAll(uploadDir, 0o755) // 确保目录存在

    r := gin.Default()
    r.Use(requestTimeout(5 * time.Second))
    r.Static(&quot;/uploads&quot;, uploadDir) // URL 前缀写死正斜杠 /uploads；目录走 uploadDir
    // /uploads/xxx.jpg → ./uploads/xxx.jpg

    // ...路由区新增：
    r.POST(&quot;/books/:id/cover&quot;, handlers.UploadCover)
}
</code></pre>

<p><code>r.Static(&quot;/uploads&quot;, uploadDir)</code> 让 <code>/uploads/&lt;文件名&gt;</code> 直接映射到磁盘目录——浏览器/前端拼 <code>coverUrl</code> 即可展示图片。</p>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X POST http://localhost:8080/books/1/cover \
  -F &quot;cover=@/path/to/cover.jpg&quot;
# {&quot;coverUrl&quot;:&quot;/uploads/1_1734xxxx.jpg&quot;}

curl -I http://localhost:8080/uploads/1_1734xxxx.jpg   # 200，Content-Type image/jpeg

# 反例：非图片 / 超 2MB 应返回 400
curl -X POST http://localhost:8080/books/1/cover -F &quot;cover=@/etc/hosts&quot;
# {&quot;error&quot;:&quot;仅允许 jpg/png/webp&quot;}
</code></pre>

<hr>

<h2 id="二-查询增强-分页-搜索-排序">二、查询增强：分页、搜索、排序</h2>

<p><strong>目标：</strong> 把 <code>GetBooks</code> 从&rdquo;全量列表&rdquo;升级为&rdquo;分页 + 搜索 + 排序&rdquo;的通用接口，并给列表加上每本书的评论数（JOIN + GROUP BY）。多表关联篇（<a href="./gorm-gin-relations">《多表关联实战》</a>）学过的分页骨架，这里在更大的表上复用。</p>

<h3 id="2-1-先收拢帮助函数">2.1 先收拢帮助函数</h3>

<p>多表关联篇的 <code>ListComments</code> 里，分页解析是内联写的（先看完整形态是正确的教学顺序）。现在把它收拢到 <code>handlers/pagination.go</code>，供所有分页接口复用：</p>

<pre><code class="language-go">// handlers/pagination.go —— 分页参数解析 + 校验（防御式，非法值回落到默认）
package handlers

import (
    &quot;strconv&quot;

    &quot;github.com/gin-gonic/gin&quot;
)

func parsePagination(c *gin.Context) (page, pageSize int) {
    page, err := strconv.Atoi(c.DefaultQuery(&quot;page&quot;, &quot;1&quot;))
    if err != nil || page &lt; 1 {
        page = 1
    }
    pageSize, _ = strconv.Atoi(c.DefaultQuery(&quot;pageSize&quot;, &quot;10&quot;))
    if pageSize &lt; 1 {
        pageSize = 10
    }
    if pageSize &gt; 100 {
        pageSize = 100 // 上限 100：防止客户端一次拉取过量数据
    }
    return page, pageSize
}
</code></pre>

<h3 id="2-2-改造-getbooks">2.2 改造 GetBooks</h3>

<blockquote>
<p>⚠️ <strong>破坏性契约变更：</strong> <code>GET /books</code> 的响应从入门篇的裸数组变为 <code>{items,total,page,pageSize}</code> 对象——已有前端需要同步适配（系列路由总表里这条记为&rdquo;1 → 3 改造&rdquo;）。</p>
</blockquote>

<p><code>handlers/book.go</code>：</p>

<pre><code class="language-go">// GetBooks 图书列表：支持 q（标题/作者模糊搜索）、page/pageSize 分页；排序固定为创建时间倒序（外部排序字段需先白名单化，见[《GORM 工程化实战（二）》](./gorm-gin-engineering-reliability)§2.1）。
// 返回 {items, total, page, pageSize}。
func GetBooks(c *gin.Context) {
    // 1. 解析查询参数
    // 1.1 q：标题/作者模糊搜索关键词
    q := c.Query(&quot;q&quot;)
    // 1.2 分页参数解析 + 校验（在 handlers/pagination.go 里的 parsePagination）
    page, pageSize := parsePagination(c)

    // 2. 组装查询：可选标题/作者模糊搜索
    query := db.DB.WithContext(c.Request.Context()).Model(&amp;models.Book{})
    if q != &quot;&quot; {
        like := &quot;%&quot; + q + &quot;%&quot;
        query = query.Where(&quot;title ILIKE ? OR author ILIKE ?&quot;, like, like)
    }

    // 3. 先 Count 过滤后的总条数
    var total int64
    if err := query.Count(&amp;total).Error; err != nil {
        _ = c.Error(err) // 具体错误交给 Gin 日志；客户端消息保持统一
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    // 4. 再取当前页（按创建时间倒序）
    var books []models.Book
    if err := query.
        Order(&quot;created_at DESC&quot;).
        Offset((page - 1) * pageSize).
        Limit(pageSize).
        Find(&amp;books).Error; err != nil {
        _ = c.Error(err) // 具体错误交给 Gin 日志；客户端消息保持统一
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    // 5. 返回分页结果
    c.JSON(http.StatusOK, gin.H{
        &quot;items&quot;:    books,
        &quot;total&quot;:    total,
        &quot;page&quot;:     page,
        &quot;pageSize&quot;: pageSize,
    })
}
</code></pre>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl &quot;http://localhost:8080/books?q=Go&amp;page=1&amp;pageSize=10&quot;
# {&quot;items&quot;:[...],&quot;total&quot;:1,&quot;page&quot;:1,&quot;pageSize&quot;:10}

curl &quot;http://localhost:8080/books?page=2&amp;pageSize=5&quot;
# 翻页：total 不变，items 为第二页
</code></pre>

<p>要点（都是 GORM 查询的骨架级知识）：</p>

<ul>
<li><strong><code>ILIKE</code> 是 PG 专用</strong>：<code>LIKE</code> 分大小写、<code>ILIKE</code> 不分。教程用 PG，写 <code>ILIKE</code>；换 MySQL 用 <code>LIKE</code>，换 SQLite 用 <code>LIKE</code>（SQLite <code>LIKE</code> 对 ASCII 不区分大小写）——驱动差异的一个具体例子。另注意 <code>q</code> 若含 <code>%</code>/<code>_</code> 会被当成通配符：参数化只防 SQL 注入、不防通配符语义，按字面搜索需先转义或加 <code>ESCAPE</code>，教程不展开；</li>
<li><strong><code>query</code> 是可复用的链</strong>：同一个 <code>query</code> 变量先 <code>Count</code> 再追加 <code>Order/Offset/Limit</code> 执行 <code>Find</code>——<code>Count</code> 前不带分页条件，得到的是<strong>过滤后的总数</strong>，这正是分页接口的标准姿势；</li>
<li><strong>ORDER 注入</strong>：如果 <code>sort</code> 参数来自用户，千万别直接拼进 <code>Order()</code>（<code>&quot;/books?sort=created_at;DROP...&quot;</code>）——教程固定排序即可，接受外部排序字段要先做白名单（落地见<a href="./gorm-gin-engineering-reliability">《GORM 工程化实战（二）》</a>§2.1）。</li>
</ul>

<h3 id="2-3-给列表加评论数-join-group-by">2.3 给列表加评论数：JOIN + GROUP BY</h3>

<p><code>Preload</code> 只能取&rdquo;评论对象数组&rdquo;，取不了&rdquo;评论条数&rdquo;。要&rdquo;每本书带评论数&rdquo;，用聚合——需要一个承载结果的结构：</p>

<pre><code class="language-go">// 列表响应结构：Book 本体 + 评论计数（不进数据库表，只做查询载体）
type BookListItem struct {
    models.Book
    CommentCount int64 `json:&quot;commentCount&quot;`
}
</code></pre>

<p>然后在 <code>GetBooks</code> 基础上<strong>只改第 4 步</strong>（第 1/2/3/5 步原样不动：分页解析仍走 <code>parsePagination</code>，总数仍用纯 <code>books</code> 表 <code>Count</code>）——把第 4 步的 <code>var books []models.Book</code> + <code>Find</code> 换成下面的 <code>var items []BookListItem</code> + <code>Scan</code>（<code>BookListItem</code> 见上方声明）：</p>

<pre><code class="language-go">var items []BookListItem
if err := query.
    Select(&quot;books.*, COUNT(comments.id) AS comment_count&quot;).
    Joins(&quot;LEFT JOIN comments ON comments.book_id = books.id AND comments.deleted_at IS NULL&quot;).
    Group(&quot;books.id&quot;).
    Order(&quot;books.created_at DESC&quot;).
    Offset((page - 1) * pageSize).
    Limit(pageSize).
    Scan(&amp;items).Error; err != nil {
    _ = c.Error(err)
    c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
    return
}
</code></pre>

<p>聚合查询的三个坑（都是教科书级的）：</p>

<ul>
<li><strong><code>LEFT JOIN</code> + <code>Group(&quot;books.id&quot;)</code></strong>：没评论的书也要出现（<code>comment_count = 0</code>），所以是 LEFT 不是 INNER；</li>
<li><strong><code>comments.deleted_at IS NULL</code> 条件要进 JOIN 而不是 WHERE</strong>：放进 WHERE 会把&rdquo;无评论的书&rdquo;整行剔除，放进 JOIN 才能保留左表 + 只数未删除的评论——聚合查询最常见的坑；</li>
<li><strong><code>Scan</code> 进自定义结构</strong>：<code>BookListItem</code> 嵌了 <code>models.Book</code> 再补一个计数字段——手写聚合 SQL 的结果装进&rdquo;查询专用载体&rdquo;，这正是 GORM 把&rdquo;原生 SQL&rdquo;接回类型世界的正规姿势（查询载体不进数据库表，只做取数）；</li>
<li><strong>字段歧义</strong>：JOIN 之后 <code>created_at</code>、<code>id</code> 在两表都有——SQL 层要写 <code>books.created_at</code>、<code>books.id</code>（<code>Group(&quot;books.id&quot;)</code> 同理），不写表前缀会报&rdquo;ambiguous column&rdquo;。</li>
</ul>

<blockquote>
<p><strong>驱动差异提示：</strong> <code>Select(&quot;books.*&quot;) + Group(&quot;books.id&quot;)</code> 能成立，依赖 PostgreSQL 的&rdquo;函数依赖&rdquo;特性——按主键分组时允许直接选其它列。MySQL 开着 <code>only_full_group_by</code>（默认开）时会报错，需要把 <code>books.*</code> 展开成完整列清单并全量分组；这是&rdquo;手写聚合 SQL&rdquo;跨数据库的典型差异，教程用 PG，不做兼容处理。</p>
</blockquote>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl &quot;http://localhost:8080/books?q=Go&amp;pageSize=10&quot;
# items[0].commentCount 应为该书的未删除评论数（无评论的书为 0）
</code></pre>

<blockquote>
<p>列表接口与详情接口的契约分层：<code>GET /books</code> 返回 <code>{items,total,page,pageSize}</code>，<code>GET /books/:id</code> 返回单本书+评论。<strong>列表轻、详情重</strong>——这是前面所有&rdquo;按需加载&rdquo;决策的落点。</p>
</blockquote>

<hr>

<h2 id="本篇小结">本篇小结</h2>

<ul>
<li><strong>本篇新增路由：</strong></li>
</ul>

<table>
<thead>
<tr>
<th>方法</th>
<th>路径</th>
<th>handler / 作用</th>
</tr>
</thead>

<tbody>
<tr>
<td>POST</td>
<td><code>/books/:id/cover</code></td>
<td>UploadCover</td>
</tr>

<tr>
<td>GET</td>
<td><code>/uploads/*</code></td>
<td><code>r.Static</code> 静态服务</td>
</tr>

<tr>
<td>GET</td>
<td><code>/books</code>（改造）</td>
<td>GetBooks 分页 + 搜索 + 评论数</td>
</tr>
</tbody>
</table>

<ul>
<li>你现在的项目：<code>books</code> + <code>comments</code> 双表、封面图上传与静态服务、分页搜索列表、每本书带评论数；</li>
<li>下一篇<a href="./gorm-gin-dto-batch">《数据工程实战》</a>：批量导入真实数据、请求 DTO 与参数化校验、校验错误的友好翻译，末尾附系列一览与工程化条目清单（系列第 <sup>6</sup>&frasl;<sub>7</sub> 篇预告的集中对账）。</li>
</ul>

<hr>

<h2 id="附-postgresql-特性速查-mysql-sqlite-对照">附：PostgreSQL 特性速查（MySQL / SQLite 对照）</h2>

<p>全系列真正依赖 PostgreSQL 的写法只有下面几处，其余都是标准 SQL，三库通用：</p>

<table>
<thead>
<tr>
<th>PG 特性</th>
<th>出现在哪</th>
<th>本系列写法</th>
<th>换 MySQL</th>
<th>换 SQLite</th>
</tr>
</thead>

<tbody>
<tr>
<td>大小写不敏感的模糊搜索</td>
<td>本篇 §2.2</td>
<td><code>ILIKE ?</code></td>
<td><code>LIKE ?</code>（大小写由 collation 决定；必要时 <code>LOWER(col) LIKE LOWER(?)</code>）</td>
<td><code>LIKE ?</code>（ASCII 内不区分大小写；非 ASCII 需 <code>COLLATE NOCASE</code>）</td>
</tr>

<tr>
<td>按主键分组后可直接选其它列（函数依赖）</td>
<td>本篇 §2.3</td>
<td><code>SELECT books.*, COUNT(comments.id) ... GROUP BY books.id</code></td>
<td>默认 <code>only_full_group_by</code> 下报错：需把 <code>books.*</code> 展开成完整列清单并全部 <code>GROUP BY</code></td>
<td>同 MySQL（无函数依赖，需全列分组）</td>
</tr>

<tr>
<td>软删除 + 唯一字段的部分唯一索引</td>
<td>入门篇第八章</td>
<td><code>CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL</code></td>
<td>无直接等价：用生成列（<code>(deleted_at IS NULL)</code> 的布尔列）做部分唯一，或应用层保证</td>
<td>同 MySQL</td>
</tr>
</tbody>
</table>
<p>另外 <code>%</code> / <code>_</code> 在 <code>ILIKE</code> 与 <code>LIKE</code> 里都是通配符（参数化只防 SQL 注入、不防通配符语义），三库一致。教程主线按 PostgreSQL 跑通；想换 MySQL / SQLite，把上面几行替换掉即可，其余代码不用动。</p>
]]></content:encoded>
      <description><![CDATA[给图书加封面：字段命名决策、上传接口与静态服务；再把列表升级为分页 + 搜索 + 排序，并用 JOIN + GROUP BY 给每本书带上评论数。每节附完整代码与验证命令。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:relation><![CDATA[series:gin-gorm]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GORM 多表关联实战：评论模型、增删查与 Preload]]></title>
      <link>https://moongate.top/docs/gorm-gin-relations</link>
      <guid isPermaLink="true">https://moongate.top/docs/gorm-gin-relations</guid>
      <pubDate>Wed, 02 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-新增第二张表-comments-一对多">一、新增第二张表 comments（一对多）</h2>

<p><strong>目标：</strong> 建立 <code>Book</code> 与 <code>Comment</code> 的一对多关系，让&rdquo;某本书下有多条评论&rdquo;成为一个真实模型。</p>

<p>为什么第二张表选评论？图书场景里&rdquo;书 → 评论&rdquo;最自然、最容易被读者代入；一对多 + 外键 + <code>Preload</code> 是多表关联的第一级台阶。第三张表（<code>tags</code>，多对多）在<a href="./gorm-gin-tags">《GORM 多对多实战》</a>兑现，一课只上一张新表。</p>

<h3 id="1-1-comment-模型">1.1 Comment 模型</h3>

<p>新建 <code>models/comment.go</code>：</p>

<pre><code class="language-go">package models

import &quot;gorm.io/gorm&quot;

type Comment struct {
    gorm.Model
    BookID   uint   `json:&quot;bookId&quot; gorm:&quot;not null;index&quot;` // 外键：属于哪本书
    Nickname string `json:&quot;nickname&quot;`                     // 评论者名（不引入用户/鉴权）
    Content  string `json:&quot;content&quot; gorm:&quot;not null&quot;`
}
</code></pre>

<p>字段说明：</p>

<ul>
<li><code>BookID</code> 是外键，<code>index</code> 让它带索引——按书查评论是最高频查询；</li>
<li>评论者的处理：用 <code>Nickname</code> 字符串，刻意不引入用户表和鉴权（那是另一个话题）；</li>
<li><code>gorm.Model</code> 自带软删除——评论同样支持软删除，行为与入门篇一致。</li>
</ul>

<h3 id="1-2-book-增加关联字段">1.2 Book 增加关联字段</h3>

<p><code>models/book.go</code> 追加：</p>

<pre><code class="language-go">type Book struct {
    gorm.Model
    Title    string    `json:&quot;title&quot; gorm:&quot;not null&quot;`
    Author   string    `json:&quot;author&quot; gorm:&quot;not null&quot;`
    Price    int       `json:&quot;price&quot;`
    Comments []Comment `json:&quot;comments,omitempty&quot;` // 关系声明，不是表列；仅用于 Preload
}
</code></pre>

<blockquote>
<p><strong>为什么这个字段连 <code>foreignKey</code> 标签都不用写？</strong> 因为 GORM 的 has-many 默认约定是&rdquo;父类型名 + 父主键字段名&rdquo;（<code>Book</code> + <code>ID</code> = <code>BookID</code>）——<code>Comment.BookID</code> 恰好命中约定，关联自动解析。<strong>什么时候必须显式写 <code>gorm:&quot;foreignKey:...&quot;</code></strong>：子表外键字段偏离约定时（比如改成 <code>BookRef</code>）；同时注意<strong>这个字段不能删</strong>——它是关系声明，<code>Preload(&quot;Comments&quot;)</code> 靠它按名字加载。</p>

<p><strong>为什么 <code>Comments</code> 不直接出现在列表响应里？</strong> 列表接口如果默认带全部评论，响应体量会膨胀、还会诱发 N+1 查询。<code>json:&quot;comments,omitempty&quot;</code> + <strong>不默认 Preload</strong>——评论只在<strong>详情接口按需加载</strong>（第三节），这是真实项目的通行做法。</p>
</blockquote>

<h3 id="1-3-迁移更新与验证">1.3 迁移更新与验证</h3>

<p><code>main.go</code> 的 <code>AutoMigrate</code> 改为同时建两张表：</p>

<pre><code class="language-go">if err := db.DB.AutoMigrate(&amp;models.Book{}, &amp;models.Comment{}); err != nil {
    log.Fatal(&quot;迁移失败：&quot;, err)
}
</code></pre>

<ul>
<li><code>books</code> 已存在，本篇<strong>不会给它加任何列</strong>——<code>Comments []Comment</code> 是「关系声明」不是列，AutoMigrate 对 <code>books</code> 本表没有动作；</li>
<li><code>comments</code> 首次迁移会建表 + 建外键（<code>book_id → books.id</code>）+ <code>index</code>。</li>
</ul>

<p><strong>验证：</strong></p>

<pre><code class="language-text">\d comments
-- 应看到 book_id 带索引、外键约束指向 books(id)，deleted_at 等 gorm.Model 字段齐全
</code></pre>

<blockquote>
<p><strong>⚠️ 软删除不联动从表：</strong> 软删除 <code>books</code> 里的一本书，只是给 <code>books.deleted_at</code> 打时间戳，<strong><code>comments.deleted_at</code> 不受影响</strong>——评论依然可见、依然可查。入门篇讲过&rdquo;软删除 = 框架改写 SQL&rdquo;，这里看到它的另一面：<strong>主表软删不会级联到子表</strong>。要&rdquo;删书连带隐藏评论&rdquo;，得自己写（比如 <code>db.Model(&amp;models.Comment{}).Where(&quot;book_id = ?&quot;, id).Update(&quot;deleted_at&quot;, time.Now())</code>，示意代码，省略了 <code>WithContext</code> 与错误检查），本篇不展开。</p>
</blockquote>

<hr>

<h2 id="二-评论管理-第二张表的增删查">二、评论管理：第二张表的增删查</h2>

<p><strong>目标：</strong> 写出评论这批资源的增删查（创建、按书分页查询、删除），并注册对应路由——入门篇的六步公式在第二张表上的完整复刻。本节暂不涉及 Preload（那是第三节的事）。</p>

<h3 id="2-1-创建评论">2.1 创建评论</h3>

<p><code>handlers/comment.go</code>：</p>

<pre><code class="language-go">package handlers

import (
    &quot;errors&quot;
    &quot;gin-demo/db&quot;
    &quot;gin-demo/models&quot;
    &quot;net/http&quot;

    &quot;github.com/gin-gonic/gin&quot;
    &quot;gorm.io/gorm&quot;
)

// CreateComment 为指定图书创建评论
func CreateComment(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 1. 书必须存在
    var book models.Book
    result := db.DB.WithContext(c.Request.Context()).First(&amp;book, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    // 2. 绑定评论内容（昵称可空、评论正文必填）
    var input struct {
        Nickname string `json:&quot;nickname&quot;`
        Content  string `json:&quot;content&quot; binding:&quot;required&quot;`
    }
    if err := c.ShouldBindJSON(&amp;input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
        return
    }

    // 3. 插入，外键指向当前书
    comment := models.Comment{
        BookID:   book.ID,
        Nickname: input.Nickname,
        Content:  input.Content,
    }
    if err := db.DB.WithContext(c.Request.Context()).Create(&amp;comment).Error; err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;创建评论失败&quot;})
        return
    }

    c.JSON(http.StatusCreated, comment)
}
</code></pre>

<blockquote>
<p>注意第 3 步没有用整个 <code>input</code> 直接 <code>Create(&amp;input)</code>——<code>input</code> 是请求 DTO，<code>comment</code> 才是模型。<strong>请求结构体和模型分离</strong>在这里从入门篇的&rdquo;局部做法&rdquo;升级为主线规则（入门篇第五章进阶已用 <code>createBookInput</code> 尝过鲜，当时两者字段还基本重合），<a href="./gorm-gin-dto-batch">《数据工程实战》</a>会把它正式落地。</p>
</blockquote>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X POST http://localhost:8080/books/1/comments \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;nickname&quot;:&quot;Alice&quot;,&quot;content&quot;:&quot;写得很清楚&quot;}'
# → 201，返回带 id（`gorm.Model` 无 json 标签，实际键是大写 `ID`/`CreatedAt`，见入门篇）的 comment
</code></pre>

<h3 id="2-2-按书分页查询评论">2.2 按书分页查询评论</h3>

<p><code>ListComments</code>——一个值得背下来的分页骨架（它用 <code>strconv.Atoi</code> 解析分页参数：若 2.1 之后你的 <code>handlers/comment.go</code> import 里还没有 <code>strconv</code>，记得补上）：</p>

<pre><code class="language-go">// ListComments 按书分页查评论，默认按创建时间倒序。
// 返回 {items, total, page, pageSize}，total 是过滤后的总条数。
func ListComments(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 1. 解析分页参数
    // 1.1 page：第几页，默认 1
    page, _ := strconv.Atoi(c.DefaultQuery(&quot;page&quot;, &quot;1&quot;))
    // 1.2 pageSize：每页条数，默认 10
    pageSize, _ := strconv.Atoi(c.DefaultQuery(&quot;pageSize&quot;, &quot;10&quot;))

    // 2. 校验分页参数（防御式，非法值回落到默认）
    // 2.1 page 最小为 1
    if page &lt; 1 {
        page = 1
    }
    // 2.2 pageSize 回落默认 10
    if pageSize &lt; 1 {
        pageSize = 10
    }
    // 2.3 pageSize 上限 100：防止客户端一次拉取过量数据
    if pageSize &gt; 100 {
        pageSize = 100
    }

    // 3. 组装查询：只查当前书的评论
    var comments []models.Comment
    query := db.DB.WithContext(c.Request.Context()).
        Model(&amp;models.Comment{}).
        Where(&quot;book_id = ?&quot;, id)

    // 4. 先 Count 过滤后的总条数
    var total int64
    if err := query.Count(&amp;total).Error; err != nil {
        _ = c.Error(err) // 具体错误交给 Gin 日志；客户端消息保持统一
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询评论失败&quot;})
        return
    }

    // 5. 再取当前页（评论按创建时间倒序）
    if err := query.
        Order(&quot;created_at DESC&quot;).
        Offset((page - 1) * pageSize).
        Limit(pageSize).
        Find(&amp;comments).Error; err != nil {
        _ = c.Error(err) // 具体错误交给 Gin 日志；客户端消息保持统一
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询评论失败&quot;})
        return
    }

    // 6. 返回分页结果
    c.JSON(http.StatusOK, gin.H{
        &quot;items&quot;:    comments,
        &quot;total&quot;:    total,
        &quot;page&quot;:     page,
        &quot;pageSize&quot;: pageSize,
    })
}
</code></pre>

<p>这是系列里第一个分页接口，先看完整形态——记住 <code>Count + Order + Offset + Limit</code> 的组合。（下一篇<a href="./gorm-gin-media-query">《文件与查询增强实战》</a>会把同一骨架用在图书列表上，并把这段解析收拢成 <code>parsePagination</code> 帮助函数。）</p>

<p>另外留意一个编码细节：<code>strconv.Atoi</code> 出错时用 <code>_</code> 丢弃，非法参数直接回落到默认值——防御式解析。</p>

<blockquote>
<p><strong>错误消息为什么统一？</strong> <code>Count</code> 与 <code>Find</code> 失败都返回「查询评论失败」是刻意的——客户端看到的是 500，不需要知道是哪一步挂了（向外暴露内部细节也不安全）；真正要区分的是<strong>服务端日志</strong>：<code>_ = c.Error(err)</code> 把具体错误交给 Gin 的日志中间件记录。生产环境会升级为 <code>slog</code> + 统一错误中间件（落地见<a href="./gorm-gin-engineering-reliability">《GORM 工程化实战（二）：可靠性与生产化》</a>）。</p>
</blockquote>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl &quot;http://localhost:8080/books/1/comments?page=1&amp;pageSize=10&quot;
# {&quot;items&quot;:[...],&quot;total&quot;:1,&quot;page&quot;:1,&quot;pageSize&quot;:10}
</code></pre>

<blockquote>
<p><strong>与创建/删除的语义差异：</strong> <code>CreateComment</code> / <code>DeleteComment</code> 都会先校验书存在（书软删后这些操作 404），<code>ListComments</code> 却不校验——书软删后 <code>GET /books/:id/comments</code> 依然 200 返回历史评论。这是刻意的：子表数据随主表软删仍可查（呼应 1.3 的 ⚠️ 框），列表接口只负责&rdquo;按条件取数&rdquo;，不负责判定资源存在。</p>
</blockquote>

<h3 id="2-3-删除评论">2.3 删除评论</h3>

<pre><code class="language-go">// DeleteComment 软删除评论：删除条件 = 评论主键 + 归属的书一致。
// 只按 cid 删会越权——URL 是 /books/:id/comments/:cid，必须确保评论属于这
// 本书，否则 /books/1/comments/99 也能删掉书 2 的评论（水平越权）。
func DeleteComment(c *gin.Context) {
    id := c.Param(&quot;id&quot;)   // 书的 id（归属校验用）
    cid := c.Param(&quot;cid&quot;) // 评论的 id（主键）

    // 主键 + Where 条件叠加：DELETE ... WHERE id = cid AND book_id = id
    result := db.DB.WithContext(c.Request.Context()).
        Where(&quot;book_id = ?&quot;, id).
        Delete(&amp;models.Comment{}, cid)

    // 先查错误、后查影响行数：cid 不是合法数字时主键转换会报错（500）；
    // 条件没命中（评论不存在 / 不属于这本书）才是 404 —— 与查询接口的双层检查一致
    if result.Error != nil {
        _ = c.Error(result.Error)
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;删除评论失败&quot;})
        return
    }
    if result.RowsAffected == 0 {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;评论不存在&quot;})
        return
    }
    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;评论已删除&quot;})
}
</code></pre>

<p><code>Delete(&amp;models.Comment{}, cid)</code> 与入门篇删书同构——六步骨架里&rdquo;做操作 + 验 <code>RowsAffected</code>&ldquo;的又一次实例化。</p>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl -X DELETE http://localhost:8080/books/1/comments/1
# → 200；再删同一条 → 404
</code></pre>

<h3 id="2-4-路由注册">2.4 路由注册</h3>

<p>以下三行放进 <code>main.go</code> 的路由区（其余路由沿用入门篇）：</p>

<pre><code class="language-go">r.POST(&quot;/books/:id/comments&quot;, handlers.CreateComment)
r.GET(&quot;/books/:id/comments&quot;, handlers.ListComments)
r.DELETE(&quot;/books/:id/comments/:cid&quot;, handlers.DeleteComment)
</code></pre>

<hr>

<h2 id="三-关联查询实战-preload-一对多">三、关联查询实战：Preload 一对多</h2>

<p><strong>目标：</strong> 用 <code>Preload(&quot;Comments&quot;)</code> 让详情接口按需加载关联评论——这是&rdquo;一对多读取&rdquo;的重头戏，也是本篇真正的新知识点。</p>

<h3 id="3-1-详情接口按需加载评论">3.1 详情接口按需加载评论</h3>

<p><code>handlers/book.go</code> 的 <code>GetBook</code> 加上一行 <code>Preload</code>：</p>

<pre><code class="language-go">// 查询单条图书（带评论，按需加载）
func GetBook(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    var book models.Book
    result := db.DB.WithContext(c.Request.Context()).Preload(&quot;Comments&quot;).First(&amp;book, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    c.JSON(http.StatusOK, book)
}
</code></pre>

<p><code>Preload(&quot;Comments&quot;)</code> 让一次 <code>First</code> 顺带查出该书全部评论：GORM 先查 <code>books</code> 再按 <code>book_id</code> 批量查 <code>comments</code> 组装——<strong>它不会触发 N+1</strong>（是两条 SQL 一次组装，不是逐行查询）。</p>

<blockquote>
<p><strong>列表接口不 Preload：</strong> <code>GetBooks</code> 保持原样（不带评论）——详情才带。<strong>接口契约由数据用途决定，不由 ORM 能力决定</strong>。</p>
</blockquote>

<p><strong>测试：</strong></p>

<pre><code class="language-bash">curl http://localhost:8080/books/1
# 详情里应包含 &quot;comments&quot;:[...]
</code></pre>

<hr>

<h2 id="本篇小结">本篇小结</h2>

<ul>
<li><strong>本篇新增路由：</strong></li>
</ul>

<table>
<thead>
<tr>
<th>方法</th>
<th>路径</th>
<th>handler</th>
</tr>
</thead>

<tbody>
<tr>
<td>POST</td>
<td><code>/books/:id/comments</code></td>
<td>CreateComment</td>
</tr>

<tr>
<td>GET</td>
<td><code>/books/:id/comments</code></td>
<td>ListComments</td>
</tr>

<tr>
<td>DELETE</td>
<td><code>/books/:id/comments/:cid</code></td>
<td>DeleteComment</td>
</tr>

<tr>
<td>GET</td>
<td><code>/books/:id</code>（改造，带 Comments）</td>
<td>GetBook</td>
</tr>
</tbody>
</table>

<ul>
<li>你现在的项目：<code>books</code> + <code>comments</code> 双表、评论增删查、详情按需加载评论；</li>
<li>下一篇<a href="./gorm-gin-media-query">《文件与查询增强实战》</a>：给书加封面图（上传 + 静态服务），并把列表升级为分页、搜索、排序，附加每本书的评论数。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[在入门篇单表 CRUD 的基础上引入第二张表 comments（一对多）：模型与迁移、评论的增删查、以及用 Preload 在详情接口按需加载关联评论。每节附完整代码与验证命令。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:relation><![CDATA[series:gin-gorm]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GORM 入门实战：用 Gin + GORM 写一个图书管理 API]]></title>
      <link>https://moongate.top/docs/gorm-gin-crud-tutorial</link>
      <guid isPermaLink="true">https://moongate.top/docs/gorm-gin-crud-tutorial</guid>
      <pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="适合读者">适合读者</h2>

<ul>
<li>已掌握 Go 基础语法</li>
<li>想学 GORM 但不知道从哪开始</li>
<li>想看到一个能直接运行的完整项目</li>
<li>有其他语言（Java/Python/Node 等）Web 开发经验更佳——本文会顺带对比常见框架的写法差异</li>
</ul>

<h2 id="环境准备">环境准备</h2>

<p>动手前确认三样东西：</p>

<ul>
<li><strong>Go 1.22+</strong>（本教程用到 <code>errors.Is</code>；泛型只在系列工程化篇出现，1.21+ 均可，推荐 1.24+）；</li>
<li><strong>本地 PostgreSQL</strong>（无安装可用 Docker：<code>docker run --name pg -e POSTGRES_PASSWORD=123456 -p 5432:5432 -d postgres:16</code>）；</li>
<li><strong>手动创建数据库</strong>：<code>CREATE DATABASE library;</code>——GORM 的 <code>AutoMigrate</code> 只能建表，不能建库（见第二章注意事项）。</li>
</ul>

<p>想换 MySQL / SQLite 也可以——本篇的代码本身跨库通用，差别只在驱动安装与 DSN（见第一章末尾对照表）。但要提前知道：<strong>入门篇之后的篇目会用 PostgreSQL 专属特性</strong>（<code>ILIKE</code> 模糊搜索、聚合查询按主键分组的&rdquo;函数依赖&rdquo;、软删除的部分唯一索引），正文会在用到处就地标注差异，并把它们集中收在媒体篇末尾的「PostgreSQL 特性速查」表里。</p>

<h2 id="完整项目结构">完整项目结构</h2>

<pre><code class="language-text">gin-demo/
├── main.go           # 入口文件
├── db/
│   └── db.go         # 数据库连接
├── models/
│   └── book.go       # 数据模型
├── handlers/
│   └── book.go       # 业务逻辑（CRUD）
└── go.mod
</code></pre>

<blockquote>
<p><strong>学习级结构：</strong> <code>db</code> / <code>models</code> / <code>handlers</code> 平铺适合小项目与入门。业务复杂后建议按职责演进——用 Go 的 <code>internal/</code> 包约束可见性、抽出 Service 层放业务逻辑、Repository 层收拢数据访问。教程保持平铺以聚焦 GORM，先跑通再谈分层。想对 handler 做单元测试、需要 mock 数据库时，再把数据访问收拢为接口（如 <code>BookRepository</code>）注入（见第十章进阶方向）。</p>
</blockquote>

<h2 id="先建立心智模型-gorm-的核心理念">先建立心智模型：GORM 的核心理念</h2>

<p>在动手写代码之前，先用三十秒建立正确的「心智模型」——它回答的是&rdquo;GORM 和别的路子哪里不一样&rdquo;。GORM 最不一样的对手不是 Spring/Django 这类框架，而是 JDBC/MyBatis 这种手写 SQL 的路子：后者要你自己拼 SQL、再把结果集一行行映射成对象，GORM 恰好反过来。先理解下面这套思维方式，再动手，比照抄代码重要得多。</p>

<h3 id="一句话总纲">一句话总纲</h3>

<blockquote>
<p>Gin 负责把「HTTP 请求」变成「Go 函数调用」，GORM 负责把「Go 结构体」翻译成「数据库 SQL」。整篇文章的思维主线只有一条：<strong>请求进来 → 装进结构体 → 交给 GORM → 结果填回结构体 → 返回 JSON</strong>。</p>
</blockquote>

<h3 id="不是写-sql-是操作结构体">不是写 SQL，是操作结构体</h3>

<p>Java 的 JDBC / MyBatis、PHP 手写 PDO 这类技术里，你需要自己拼 SQL 字符串，再把结果集一行行手动映射成对象。GORM 反过来：你只描述<strong>意图</strong>（<code>Create</code>、<code>Find</code>、<code>Updates</code>、<code>Delete</code>），翻译成 SQL 是框架的事——</p>

<table>
<thead>
<tr>
<th>GORM 方法（节选）</th>
<th>对应 SQL 意图</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>db.Create(&amp;book)</code></td>
<td><code>INSERT INTO books ...</code></td>
</tr>

<tr>
<td><code>db.Model(&amp;book).Updates(input)</code></td>
<td><code>UPDATE books SET ...</code></td>
</tr>

<tr>
<td><code>db.Delete(&amp;models.Book{}, id)</code></td>
<td><code>UPDATE books SET deleted_at = NOW() ...</code>（软删除）</td>
</tr>
</tbody>
</table>
<p>想确认 GORM 到底生成了什么 SQL？开启 GORM 日志就能看到（见第十章）。带着「意图」写代码，不要试图在脑子里逐条翻译 SQL；完整的方法 ↔ SQL 对照见第十章总结。</p>

<h3 id="四个必须建立的心智">四个必须建立的心智</h3>

<ol>
<li><strong>结构体一物三用</strong>：同一个 struct 同时扮演三个角色——数据库表结构定义（<code>gorm</code> 标签）、请求/响应的数据载体（<code>json</code> 标签）、数据库操作的参数（<code>&amp;book</code>）。类型即契约，改一处全联动。这与 Java 中 Entity / DTO 分离、再配一套 XML 映射的写法完全不同。</li>
<li><strong>查数据是「填空」，不是「返回值」</strong>：Go 是值传递，所以 <code>Find(&amp;books)</code>、<code>First(&amp;book, id)</code> 必须传目标变量的<strong>指针</strong>，GORM 靠反射把结果填进去。漏写 <code>&amp;</code> 等于填了一个副本，函数外拿不到数据——这是新手最容易犯、也最反直觉的一处，因为 Java/Python 的对象引用天然是共享的。</li>
<li><strong>零值即「未提供」</strong>：Go 规定每个变量都有零值（数字 <code>0</code>、字符串 <code>&quot;&quot;</code>、布尔 <code>false</code>）。GORM 的 <code>Updates(结构体)</code> 正是根据零值判断「这个字段要不要更新」——所以把字段更新成 <code>0</code> 或 <code>&quot;&quot;</code> 会被<strong>静默跳过</strong>（不报错，也不更新）。第七章的「零值陷阱」根就在这里。对比 Java 的 <code>null</code>、Python 的 <code>None</code>——它们表示&rdquo;没有值&rdquo;；Go 的零值却是一个真实的值，<code>0</code> 明明是&rdquo;想把字段更新成 0&rdquo;的意图，却被 GORM 当成&rdquo;未提供&rdquo;。这种差异正是零值陷阱对新手最反直觉的地方。</li>
<li><strong>错误是结果的一部分</strong>：Go 没有异常机制。GORM 把每次操作的结果封装成 <code>result</code>，你要自己检查 <code>result.Error</code> 有没有错、<code>result.RowsAffected</code> 影响了几行。整篇文章你会反复看到这个模式——它取代了其他语言里的 <code>try/catch</code>。</li>
</ol>

<h3 id="小注-为什么到处是-和">小注：为什么到处是 <code>&amp;</code> 和 <code>*</code>？</h3>

<p>新手最容易卡在这一处——GORM 和 Gin 的代码里满是 <code>&amp;</code> 与 <code>*</code>，却说不上来为什么。其实它们各管一件事：</p>

<ul>
<li><strong><code>&amp;x</code>（取地址传参）=「这个变量归你填，改完要带回来」</strong>：Go 默认值传递，传下去的是拷贝。GORM 的 <code>First(&amp;book, id)</code>、<code>Find(&amp;books)</code> 和 Gin 的 <code>c.ShouldBindJSON(&amp;book)</code> 都要往里<strong>填</strong>数据，<code>Delete(&amp;models.Book{}, id)</code>、<code>AutoMigrate(&amp;models.Book{})</code> 要拿到<strong>对象本身</strong>——不传 <code>&amp;</code>，函数外拿不到结果（这就是心智点 2「填空」的通用版：不止查询，绑定/删除/迁移全在用）。</li>
<li><strong><code>*gorm.DB</code> / <code>*gin.Context</code>（指针类型声明）=「引用同一个实例，不拷贝」</strong>：这两个类型本身就被声明成指针。<code>db.DB</code> 是数据库连接句柄、<code>*gin.Context</code> 是请求上下文——全局只有一份，到处传递的是<strong>指向它的地址</strong>，省拷贝且保证操作的是同一实例。</li>
<li><strong><code>*string</code> / <code>*int</code>（DTO 指针字段）=「可能没传」</strong>：<code>nil</code> 表示&rdquo;这个字段没出现&rdquo;，与空串/0 区分开——第七章正文用的是结构体与 map，指针 DTO 的正式落地在<a href="./gorm-gin-dto-batch">《数据工程实战》</a>与第十章进阶方向。</li>
</ul>

<p>一句话：<strong><code>&amp;</code> 是「填这里」，<code>*</code> 是「这就是引用/可能没有」</strong>——它们不是 GORM 的魔法，是 Go 传值与引用语义的体现，所有框架都一样。</p>

<h3 id="软删除-框架改写-sql-的又一个例子">软删除：框架改写 SQL 的又一个例子</h3>

<p>第八章的 <code>Delete</code> 不会真的删除数据——GORM 会把它改写成「软删除」，删掉的记录以后也不会再出现在查询里。具体机制留到第八章展开，先记住：<strong>别指望 <code>Delete</code> 一定生成 <code>DELETE</code> 语句</strong>（上面对照表中的「（软删除）」就是伏笔）。</p>

<h3 id="和本文各章的关系">和本文各章的关系</h3>

<ul>
<li>第二、三、四章（连接、模型、迁移）——「结构体 ↔ 表」的地基；</li>
<li>第五~八章（增删改查）——上面四个心智点的实战演练；</li>
<li>第九、十章（路由、总结）——把一切串起来并回顾。</li>
</ul>

<p>现在带着这套心智进入第一章，边敲代码边印证。</p>

<h2 id="第一章-项目初始化">第一章：项目初始化</h2>

<h3 id="目标-创建项目目录-安装依赖">目标：创建项目目录，安装依赖</h3>

<pre><code class="language-bash">mkdir gin-demo
cd gin-demo
go mod init gin-demo
</code></pre>

<h3 id="安装依赖">安装依赖</h3>

<pre><code class="language-bash"># Web 框架
go get github.com/gin-gonic/gin

# ORM 库 + PostgreSQL 驱动
go get gorm.io/gorm
go get gorm.io/driver/postgres
</code></pre>

<blockquote>
<p><code>go get</code> 会按当前环境解析最新兼容版本（等价于 <code>@latest</code>）。教程不用 <code>go get -u</code>——<code>-u</code> 会连坐升级所有间接依赖，没必要时反而引入不确定性。</p>
</blockquote>

<h3 id="数据库驱动说明">数据库驱动说明</h3>

<p>本文使用 PostgreSQL 作为示例数据库。如果你使用的是其他数据库，替换对应的驱动即可：</p>

<table>
<thead>
<tr>
<th>数据库</th>
<th>安装命令</th>
<th>DSN 格式</th>
</tr>
</thead>

<tbody>
<tr>
<td>PostgreSQL</td>
<td><code>go get gorm.io/driver/postgres</code></td>
<td><code>host=localhost user=postgres password=123456 dbname=library sslmode=disable</code></td>
</tr>

<tr>
<td>MySQL</td>
<td><code>go get gorm.io/driver/mysql</code></td>
<td><code>user:pass@tcp(localhost:3306)/library?charset=utf8mb4&amp;parseTime=True</code></td>
</tr>

<tr>
<td>SQLite</td>
<td><code>go get gorm.io/driver/sqlite</code></td>
<td><code>./data.db</code></td>
</tr>
</tbody>
</table>

<h2 id="第二章-连接数据库">第二章：连接数据库</h2>

<h3 id="目标-建立数据库连接-在项目启动时初始化">目标：建立数据库连接，在项目启动时初始化</h3>

<h3 id="连接流程">连接流程</h3>

<ol>
<li><code>main.go</code> 启动时调用 <code>db.InitDB()</code></li>
<li><code>db.InitDB()</code> 构造 DSN 字符串，通过 <code>gorm.Open()</code> 建立连接</li>
<li>连接成功返回 <code>*gorm.DB</code> 实例，失败则退出程序</li>
</ol>

<hr>

<p>创建 <code>db/db.go</code>：</p>

<pre><code class="language-go">package db

import (
    &quot;fmt&quot;
    &quot;log&quot;

    &quot;gorm.io/driver/postgres&quot;
    &quot;gorm.io/gorm&quot;
)

var DB *gorm.DB

func InitDB() {
    host := &quot;localhost&quot;
    port := 5432
    user := &quot;postgres&quot;
    password := &quot;123456&quot;
    dbname := &quot;library&quot;

    dsn := fmt.Sprintf(&quot;host=%s port=%d user=%s password=%s dbname=%s sslmode=disable&quot;,
        host, port, user, password, dbname)

    var err error
    DB, err = gorm.Open(postgres.Open(dsn), &amp;gorm.Config{})
    if err != nil {
        log.Fatal(&quot;数据库连接失败：&quot;, err)
    }
    log.Println(&quot;数据库连接成功&quot;)
}
</code></pre>

<blockquote>
<p>⚠️ 生产环境请用环境变量（如 <code>os.Getenv</code> 或 <code>godotenv</code>）管理敏感配置，不要硬编码。</p>
</blockquote>

<h3 id="代码说明">代码说明</h3>

<table>
<thead>
<tr>
<th>代码</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>var DB *gorm.DB</code></td>
<td>声明全局 DB 变量，供其他包使用</td>
</tr>

<tr>
<td><code>gorm.Open(postgres.Open(dsn), &amp;gorm.Config{})</code></td>
<td>建立数据库连接</td>
</tr>

<tr>
<td><code>log.Fatal</code></td>
<td>连接失败时终止程序，避免后续代码执行</td>
</tr>
</tbody>
</table>

<h3 id="dsn-参数说明">DSN 参数说明</h3>

<table>
<thead>
<tr>
<th>参数</th>
<th>示例</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>host</code></td>
<td><code>localhost</code></td>
<td>数据库主机地址</td>
</tr>

<tr>
<td><code>port</code></td>
<td><code>5432</code></td>
<td>PostgreSQL 默认端口</td>
</tr>

<tr>
<td><code>user</code></td>
<td><code>postgres</code></td>
<td>数据库用户名</td>
</tr>

<tr>
<td><code>password</code></td>
<td><code>123456</code></td>
<td>数据库密码</td>
</tr>

<tr>
<td><code>dbname</code></td>
<td><code>library</code></td>
<td>数据库名称</td>
</tr>

<tr>
<td><code>sslmode</code></td>
<td><code>disable</code></td>
<td>本地开发禁用 SSL</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意事项：</strong></p>

<ul>
<li>PostgreSQL 需要<strong>手动创建数据库</strong>：<code>CREATE DATABASE library;</code></li>
<li>GORM 的 <code>AutoMigrate</code> 能自动创建表，但<strong>不能自动创建数据库</strong></li>
<li><code>sslmode=disable</code> 仅用于本地开发，生产环境应开启 SSL</li>
</ul>
</blockquote>

<h2 id="第三章-定义数据模型">第三章：定义数据模型</h2>

<h3 id="目标-用-go-结构体定义数据库表结构">目标：用 Go 结构体定义数据库表结构</h3>

<p>创建 <code>models/book.go</code>：</p>

<pre><code class="language-go">package models

import &quot;gorm.io/gorm&quot;

type Book struct {
    gorm.Model
    Title  string  `json:&quot;title&quot; gorm:&quot;not null&quot;`
    Author string  `json:&quot;author&quot; gorm:&quot;not null&quot;`
    Price  int     `json:&quot;price&quot;` // 单位：分（5990 表示 59.90 元）
}
</code></pre>

<h3 id="字段说明">字段说明</h3>

<ul>
<li><code>gorm.Model</code>：内置了 <code>ID</code>、<code>CreatedAt</code>、<code>UpdatedAt</code>、<code>DeletedAt</code> 四个字段</li>
<li><code>gorm:&quot;not null&quot;</code>：对应数据库的 <code>NOT NULL</code> 约束</li>
<li><code>json:&quot;title&quot;</code>：指定 JSON 序列化时的字段名，响应体里也用它</li>
</ul>

<blockquote>
<p><strong>三个新手最容易忽略的点：</strong></p>

<ul>
<li><strong><code>gorm.Model</code> 的字段会以大写的 Go 字段名出现在 JSON 里</strong>：<code>gorm.Model</code> 没有 json 标签，所以响应里是 <code>&quot;ID&quot;</code>、<code>&quot;CreatedAt&quot;</code>、<code>&quot;DeletedAt&quot;: null</code> 这样的原名（注意大写）。想隐藏或统一命名，就用<strong>自定义字段声明</strong>替代内嵌 <code>gorm.Model</code>（字段上加 <code>json:&quot;-&quot;</code> 或小写 tag），或定义专门的 DTO（数据传输对象）作为响应结构。</li>
<li><strong>金额统一用 <code>int</code>（单位：分）</strong>：浮点数有精度误差（<code>0.1 + 0.2 != 0.3</code>），金额字段直接用整数（单位：分）规避——接口里 <code>price: 5990</code> 表示 59.90 元。前端需要&rdquo;元&rdquo;时自行除以 100（或自定义 <code>MarshalJSON</code>）；涉及汇率/费率等需要精确小数的业务才引入 <code>decimal</code> 库（见第十章进阶方向）。</li>
<li><strong><code>binding:&quot;required&quot;</code> 自动校验（加在 DTO 上，不是模型上）</strong>：给请求结构（DTO）的字段加上 <code>binding:&quot;required&quot;</code> 后，Gin 在 <code>ShouldBindJSON</code> 阶段就会校验，缺少必填字段直接返回 400——第五章进阶会实际用到。</li>
</ul>
</blockquote>

<h3 id="命名规则-重要">命名规则（重要）</h3>

<ul>
<li><strong>结构体类型名用单数</strong>：<code>Book</code> 而不是 <code>Books</code>。这是 Go 的惯例（标准库里的 <code>http.Server</code>、<code>time.Time</code> 都是单数），语义也更清晰——一个结构体实例就是一条记录。写成 <code>Books</code> 不会让表名变成别的，反而会和 <code>CreateBook</code>、<code>GetBook</code> 这类单数函数名冲突。</li>
<li><strong>表名由 GORM 自动复数化</strong>：类型 <code>Book</code> 对应的表名是 <code>books</code>（由 inflection 库处理）；即使把类型写成 <code>Books</code>，表名也还是 <code>books</code>，所以复数类型名没有任何数据库上的收益。</li>
<li><strong>文件名可以用复数，但类型名必须是单数</strong>：<code>book.go</code> 和 <code>books.go</code> 都合法（一个文件放一组相关类型时复数更常见），本教程统一用单数 <code>book.go</code>。</li>
<li>需要自定义表名时，用 <code>TableName()</code> 方法：</li>
</ul>

<pre><code class="language-go">func (Book) TableName() string { return &quot;my_books&quot; }
</code></pre>

<p><strong>三套命名：Go 字段、数据库列、JSON 各说各话：</strong></p>

<p>同一个逻辑字段在三个层有三个名字，互不冲突——翻译者是 GORM（列名）和 <code>json:</code> 标签（JSON）：</p>

<table>
<thead>
<tr>
<th>层</th>
<th>名字</th>
<th>由谁决定</th>
</tr>
</thead>

<tbody>
<tr>
<td>Go 字段</td>
<td><code>BookID</code>（PascalCase）</td>
<td>Go 标识符惯例</td>
</tr>

<tr>
<td>数据库列</td>
<td><code>book_id</code>（snake_case）</td>
<td>GORM 的 <code>NamingStrategy</code> 从字段名自动转换，一般无需 <code>column:</code></td>
</tr>

<tr>
<td>JSON 输出</td>
<td><code>bookId</code>（camelCase）</td>
<td><code>json:&quot;bookId&quot;</code> 标签，只影响序列化</td>
</tr>
</tbody>
</table>
<p>列名与 JSON 名互不干扰——以表中假设的 <code>BookID</code> 为例（你项目里换成 <code>Title</code>/<code>Author</code> 等真实字段同理）：库里永远是 <code>book_id</code>，前端看到的是 <code>bookId</code>。想改列名用 <code>gorm:&quot;column:...&quot;</code>，想让 JSON 叫别的用 <code>json:</code> 标签——两个开关各管各的。</p>

<blockquote>
<p><strong>补充：没有 <code>json:</code> 标签时呢？</strong> 上面表格讲的是&rdquo;有 json 标签&rdquo;的字段。没标签的（比如内嵌 <code>gorm.Model</code> 的 <code>ID</code>、<code>CreatedAt</code>）会<strong>直接输出 Go 字段原名</strong>——所以第一次 <code>curl</code> 时你会看到 <code>&quot;ID&quot;</code>、<code>&quot;DeletedAt&quot;</code> 这种大写键（见第三章「三个新手最容易忽略的点」）。</p>
</blockquote>

<h2 id="第四章-自动迁移">第四章：自动迁移</h2>

<h3 id="目标-程序启动时自动创建或更新表结构">目标：程序启动时自动创建或更新表结构</h3>

<p>在 <code>main.go</code> 中添加：</p>

<pre><code class="language-go">package main

import (
    &quot;log&quot;
    &quot;gin-demo/db&quot;
    &quot;gin-demo/models&quot;
    &quot;github.com/gin-gonic/gin&quot;
)

func main() {
    // 1. 连接数据库
    db.InitDB()

    // 2. 自动迁移（建表）
    if err := db.DB.AutoMigrate(&amp;models.Book{}); err != nil {
        log.Fatal(&quot;迁移失败：&quot;, err)
    }

    // 3. 启动 Gin 服务
    r := gin.Default()
    // ... 路由
    r.Run(&quot;:8080&quot;)
}
</code></pre>

<blockquote>
<p>本章的 <code>main.go</code> 是骨架版本，第九章会给出注册完所有路由的最终完整版。</p>
</blockquote>

<h3 id="注意事项">注意事项</h3>

<ul>
<li><code>AutoMigrate</code> 会创建缺失的表、列和索引，但<strong>不会删除已有字段</strong>（保护数据）</li>
<li>当字段的 <code>size</code>、<code>precision</code>、可空性（nullable）等属性变化时，GORM 会<strong>尝试修改已有列的类型</strong></li>
<li>字段重命名不会同步改列名：比如把 <code>Title</code> 改成 <code>Name</code>，GORM 会新增一列而不是改名，此时需要手动迁移或用 <code>db.Migrator().RenameColumn()</code></li>
</ul>

<h2 id="先看公式-crud-的统一流程">先看公式：CRUD 的统一流程</h2>

<p>第五到第八章的五个 handler，看起来各写各的，实际全是<strong>同一套流程的实例化</strong>。先看公式，再进代码——和心智模型一样，先拿到地图再进迷宫。</p>

<p><strong>六步骨架：</strong></p>

<ol>
<li><strong>抓参数</strong>：<code>id := c.Param(&quot;id&quot;)</code> 或 <code>c.Query(&quot;keyword&quot;)</code>——Gin 的数据入口之一</li>
<li><strong>绑请求</strong>：<code>c.ShouldBindJSON(&amp;xxx)</code>，失败直接返回 400</li>
<li><strong>查存在</strong>：<code>db.DB.First(&amp;xxx, id)</code>，查无记录（<code>errors.Is</code> 命中 <code>gorm.ErrRecordNotFound</code>）→ 404</li>
<li><strong>做操作</strong>：<code>Create</code> / <code>Find</code> / <code>Updates</code> / <code>Delete</code>——每次操作都返回 <code>result</code></li>
<li><strong>验结果</strong>：非「查无记录」的 <code>result.Error</code> → 500；<code>RowsAffected == 0</code> → 404</li>
<li><strong>回响应</strong>：<code>c.JSON(200/201, ...)</code></li>
</ol>

<p><strong>每章都是公式的一个实例：</strong></p>

<table>
<thead>
<tr>
<th>操作</th>
<th>抓参数</th>
<th>绑请求</th>
<th>查存在</th>
<th>做操作</th>
<th>验结果</th>
<th>响应</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>POST /books</code></td>
<td>–</td>
<td>✓</td>
<td>–</td>
<td><code>Create</code></td>
<td><code>Error</code> → 500</td>
<td>201</td>
</tr>

<tr>
<td><code>GET /books</code></td>
<td>–</td>
<td>–</td>
<td>–</td>
<td><code>Find</code></td>
<td><code>Error</code> → 500</td>
<td>200</td>
</tr>

<tr>
<td><code>GET /books/:id</code></td>
<td>✓</td>
<td>–</td>
<td><code>First</code> → 404</td>
<td>–</td>
<td><code>ErrRecordNotFound</code> → 404</td>
<td>200</td>
</tr>

<tr>
<td><code>PUT /books/:id</code></td>
<td>✓</td>
<td>✓</td>
<td><code>First</code> → 404</td>
<td><code>Updates</code></td>
<td><code>Error</code> → 500</td>
<td>200</td>
</tr>

<tr>
<td><code>DELETE /books/:id</code></td>
<td>✓</td>
<td>–</td>
<td>–</td>
<td><code>Delete</code></td>
<td><code>Error</code> → 500；<code>RowsAffected</code>=0 → 404</td>
<td>200</td>
</tr>
</tbody>
</table>
<p><strong>Gin + GORM 特化说明（与其他框架最不一样的地方）：</strong></p>

<ul>
<li><strong>Gin 的数据入口只有一个 <code>*gin.Context</code></strong>：路径参数（<code>c.Param</code>）、查询参数（<code>c.Query</code>）、请求体（<code>c.ShouldBindJSON</code>）都从它身上拿——没有控制器类、没有依赖注入，一个函数签名通吃所有请求。</li>
<li><strong>GORM 每一步都返回同一个 <code>result</code></strong>：<code>*gorm.DB</code> 既是链式调用的承接者，也是 <code>Error</code> 和 <code>RowsAffected</code> 的载体——「验结果」这一步，就是心智模型里「错误是结果的一部分」落到 API 层的形态。</li>
<li><strong>传指针的分野</strong>：要往变量里填数据就必须传 <code>&amp;</code>（<code>Create(&amp;book)</code>、<code>First(&amp;book, id)</code>）；删除操作不需要填数据，传类型即可（<code>Delete(&amp;models.Book{}, id)</code>）。</li>
<li><strong>状态码即约定</strong>：400 参数问题 / 404 不存在 / 500 服务端错误 / 201 创建成功，整套文章都用这套映射。</li>
<li><strong>生产惯例：每个 db 调用都链上请求上下文</strong>：<code>db.WithContext(c.Request.Context()).Xxx(...)</code>——机制与超时中间件见第五章进阶。</li>
</ul>

<p>现在进入第五章，对照公式看 <code>CreateBook</code> 是怎么实例化第 2、4、5、6 步的。</p>

<h2 id="第五章-创建图书">第五章：创建图书</h2>

<h3 id="目标-实现-post-books-接口-接收-json-请求并存入数据库">目标：实现 <code>POST /books</code> 接口，接收 JSON 请求并存入数据库</h3>

<blockquote>
<p><strong>注：</strong> 后续第六、七、八章的所有 CRUD 函数均追加至同一个文件 <code>handlers/book.go</code> 中。开头统一为：</p>
</blockquote>

<pre><code class="language-go">package handlers

import (
    &quot;errors&quot;
    &quot;gin-demo/db&quot;
    &quot;gin-demo/models&quot;
    &quot;net/http&quot;

    &quot;github.com/gin-gonic/gin&quot;
    &quot;gorm.io/gorm&quot;
)
</code></pre>

<h3 id="创建图书">创建图书</h3>

<pre><code class="language-go">// CreateBook 创建图书（POST /books）
func CreateBook(c *gin.Context) {
    var book models.Book

    // 1. 绑定 JSON 请求体
    if err := c.ShouldBindJSON(&amp;book); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
        return
    }

    // 2. 插入数据库
    result := db.DB.WithContext(c.Request.Context()).Create(&amp;book)
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;创建失败&quot;})
        return
    }

    // 3. 返回创建的数据（这就是「结构体一物三用」：请求装进来、DB 插进去、原样返回）
    c.JSON(http.StatusCreated, book)
}
</code></pre>

<blockquote>
<p>⚠️ <strong>测试时机：</strong> 路由到第九章才注册，当前 <code>main.go</code> 只是骨架（<code>// ... 路由</code>）。建议先跳到第九章、把完整版 <code>main.go</code> 抄下来跑起服务，再回头逐章测试第五~八章接口（handler 代码按各章顺序照写即可）。</p>
</blockquote>

<p><strong>测试</strong>：</p>

<pre><code class="language-bash">curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;title&quot;:&quot;Go语言实战&quot;,&quot;author&quot;:&quot;张三&quot;,&quot;price&quot;:5990}'
</code></pre>

<p><strong>响应示例（注意键名大小写）：</strong></p>

<pre><code class="language-json">{
  &quot;ID&quot;: 1,
  &quot;CreatedAt&quot;: &quot;2026-09-05T13:05:46+08:00&quot;,
  &quot;UpdatedAt&quot;: &quot;2026-09-05T13:05:46+08:00&quot;,
  &quot;DeletedAt&quot;: null,
  &quot;title&quot;: &quot;Go语言实战&quot;,
  &quot;author&quot;: &quot;张三&quot;,
  &quot;price&quot;: 5990
}
</code></pre>

<blockquote>
<p>注意键名大小写：<code>ID</code> / <code>CreatedAt</code> / <code>DeletedAt</code> 是大写，因为 <code>gorm.Model</code> 没有 json 标签，直接输出 Go 字段原名（见第三章「三个新手最容易忽略的点」）；<code>title</code> / <code>author</code> / <code>price</code> 是我们自己用小写 tag 定义的。想统一成全小写（<code>id</code>、<code>createdAt</code>），有两条路：改用自定义字段声明，或定义 DTO 作为统一响应结构（见第十章进阶方向「响应精简」）。</p>
</blockquote>

<h3 id="进阶-为什么必须带-context">进阶：为什么必须带 Context</h3>

<p>注意上面的写法里有一个容易忽略的生产细节：所有数据库调用都链上了 <code>WithContext(c.Request.Context())</code>。下面拆开讲它是什么、为什么必须带、怎么配超时。</p>

<p><strong>WithContext 是什么？</strong></p>

<p><code>WithContext(ctx)</code> 是 GORM 链式 API 的一环，把 Go 的 <code>context.Context</code> 挂到本次查询链上；执行 SQL 时，这个 ctx 会贯穿 <code>database/sql</code> → 数据库驱动（Postgres 驱动底层是 pgx），成为<strong>取消与超时的信号来源</strong>。它和 <code>Where</code> / <code>Order</code> 一样只是链上的一个方法——不带它查询也能跑：</p>

<pre><code class="language-go">// 不带：查询照跑，但无法感知请求是否已终止
db.DB.First(&amp;book, id)

// 带：ctx 的取消 / 超时信号会传导到数据库驱动层
db.DB.WithContext(c.Request.Context()).First(&amp;book, id)
</code></pre>

<p><strong>为什么必须带？</strong></p>

<p><code>c.Request.Context()</code> 的生命周期绑定在请求上：<strong>客户端断开连接时它会被取消</strong>。带上了它，数据库查询会随之中断、连接归还连接池而不是挂死；不带 Context 的裸写法（如 <code>db.DB.Create(&amp;book)</code>）也能跑，但查询对请求生命周期毫无感知——所以从第五章起，正文代码统一使用带 Context 的写法（<code>Create</code> / <code>Find</code> / <code>Updates</code> / <code>Delete</code> 同理）。</p>

<p><strong>相关配置：请求级超时中间件</strong></p>

<p>客户端断开由框架自动取消，但服务端还要防&rdquo;慢查询拖垮连接&rdquo;——用中间件给每个请求挂一个超时兜底（函数放在 <code>main.go</code>，第九章的完整版会注册它）：</p>

<pre><code class="language-go">// 完整函数与注册见第九章 main.go；核心就是四行：
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()                         // 释放计时器，防止泄漏
c.Request = c.Request.WithContext(ctx) // 不写回则下游 WithContext 感知不到超时
c.Next()
</code></pre>

<p>超时后 GORM 返回 <code>context.DeadlineExceeded</code>，会走现有的 500 分支（生产环境可进一步映射 504，见第十章进阶方向）。</p>

<h3 id="进阶-加参数校验">进阶：加参数校验</h3>

<p>上面的代码不校验请求内容，<code>{&quot;price&quot;:5990}</code>（没有 <code>title</code> / <code>author</code>）也能插入成功。校验标签加在哪？——<strong>加在&rdquo;收请求的结构&rdquo;上，而不是 <code>models.Book</code> 上</strong>：</p>

<pre><code class="language-go">// createBookInput：创建图书专用的请求结构（DTO），校验标签只属于它
type createBookInput struct {
    Title  string `json:&quot;title&quot; binding:&quot;required&quot;`
    Author string `json:&quot;author&quot; binding:&quot;required&quot;`
    Price  int    `json:&quot;price&quot; binding:&quot;gte=0&quot;`
}
</code></pre>

<p><strong>为什么不在 <code>models.Book</code> 上加 <code>binding:&quot;required&quot;</code>？</strong> 同一个模型还被第七章的更新接口复用——更新是部分更新（只传 <code>{&quot;price&quot;:6990}</code>，见零值陷阱），模型上有 <code>required</code> 会让它被 400 拦下。所以：<strong>模型管数据库结构与 JSON 命名（<code>gorm</code> / <code>json</code> 标签），校验是接口契约，属于请求结构（DTO）</strong>；系列正篇<a href="./gorm-gin-dto-batch">《GORM 数据工程实战》</a>会正式展开 DTO。</p>

<p><code>CreateBook</code> 的绑定目标换成这个结构，再映射成模型：</p>

<pre><code class="language-go">var input createBookInput
if err := c.ShouldBindJSON(&amp;input); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
    return
}
book := models.Book{Title: input.Title, Author: input.Author, Price: input.Price}
</code></pre>

<p>加上后，请求体缺少必填字段时 <code>ShouldBindJSON</code> 会直接返回校验错误，自动走现有的 400 分支：</p>

<pre><code class="language-bash"># 缺少必填字段，返回 400
curl -X POST http://localhost:8080/books \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;price&quot;:5990}'
</code></pre>

<h2 id="第六章-查询图书">第六章：查询图书</h2>

<h3 id="目标-实现查询列表和查询单条两个接口">目标：实现查询列表和查询单条两个接口</h3>

<pre><code class="language-go">// GetBooks 查询所有图书（GET /books）
func GetBooks(c *gin.Context) {
    books := []models.Book{} // 空切片而非 nil：表为空时 JSON 输出 []，而不是 null
    result := db.DB.WithContext(c.Request.Context()).Find(&amp;books)
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }
    c.JSON(http.StatusOK, books)
}

// GetBook 查询单条图书（GET /books/:id）
func GetBook(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    var book models.Book
    result := db.DB.WithContext(c.Request.Context()).First(&amp;book, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    c.JSON(http.StatusOK, book)
}
</code></pre>

<p><strong>测试</strong>：</p>

<pre><code class="language-bash"># 查询所有
curl http://localhost:8080/books

# 查询单条
curl http://localhost:8080/books/1
</code></pre>

<blockquote>
<p><strong>提示：</strong> 为什么 <code>First</code> 的检查分两层？GORM 查不到记录时返回的是 <code>gorm.ErrRecordNotFound</code>，用 <code>errors.Is</code> 精确命中它才返回 404（&rdquo;图书不存在&rdquo;）；其它错误——比如数据库连接断开——会落到 500，不会误报成&rdquo;图书不存在&rdquo;。这就是心智模型里「错误是结果的一部分」在 API 层的落点——查无记录与连接断开是两种不同的 <code>error</code>，值得用 <code>errors.Is</code> 区分。</p>

<p>（<code>GetBooks</code> 为什么用 <code>[]models.Book{}</code> 初始化而不是 <code>var books []models.Book</code>？<code>Find</code> 查不到行时不会给 <code>var</code> 声明的切片分配内存，它保持为 nil，JSON 会输出 <code>null</code> 而不是 <code>[]</code>——空表场景下前端解析就会踩坑，初始化成空切片是列表接口的标准姿势，后续文章的分页列表会沿用。）</p>
</blockquote>

<h2 id="第七章-更新图书">第七章：更新图书</h2>

<h3 id="目标-实现-put-books-id-接口">目标：实现 <code>PUT /books/:id</code> 接口</h3>

<pre><code class="language-go">// UpdateBook 更新图书（PUT /books/:id）
func UpdateBook(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 1. 检查图书是否存在
    var book models.Book
    result := db.DB.WithContext(c.Request.Context()).First(&amp;book, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;查询失败&quot;})
        return
    }

    // 2. 绑定 JSON 请求体
    var input models.Book
    if err := c.ShouldBindJSON(&amp;input); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
        return
    }

    // 3. 更新字段（仅更新 input 中的非零字段，WHERE 条件由上方 First 决定）
    result = db.DB.WithContext(c.Request.Context()).Model(&amp;book).Updates(input)
    if result.Error != nil {
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;更新失败&quot;})
        return
    }

    // 4. 返回更新后的数据（GORM 会把更新的非零字段回写 book，与落库一致；零值字段保持上方 First 加载的值）
    c.JSON(http.StatusOK, book)
}
</code></pre>

<h3 id="put-还是-patch">PUT 还是 PATCH？</h3>

<p>严格按照 REST 语义，<code>PUT /books/:id</code> 表示「整体替换」，只更新部分字段应该用 <code>PATCH /books/:id</code>。本章的 <code>Updates</code> 只更新传入的非零字段，本质上是部分更新（PATCH）语义。入门示例用 <code>PUT</code> 没问题，但你应该知道两者的区别——如果希望语义更严谨，把路由改成 <code>r.PATCH(&quot;/books/:id&quot;, handlers.UpdateBook)</code>，测试命令相应改为 <code>curl -X PATCH ...</code> 即可。</p>

<h3 id="零值陷阱与进阶思考">零值陷阱与进阶思考</h3>

<p><code>Updates</code> 传入<strong>结构体</strong>时，GORM 默认会忽略零值字段（<code>0</code>、<code>&quot;&quot;</code>、<code>false</code> 等）。这是设计如此，通常能满足 90% 的业务场景。但如果你确实需要将某个字段更新为 <code>0</code> 或 <code>&quot;&quot;</code>，有两种方案：</p>

<h4 id="方案一-用-select-强制指定字段">方案一：用 <code>Select</code> 强制指定字段</h4>

<pre><code class="language-go">db.DB.WithContext(c.Request.Context()).Model(&amp;book).Select(&quot;Price&quot;).Updates(input)
</code></pre>

<h4 id="方案二-用-map-string-interface-更通用-推荐">方案二：用 <code>map[string]interface{}</code>（更通用，推荐）</h4>

<pre><code class="language-go">// 前端只传需要更新的字段，零值也能正常更新
var inputMap map[string]interface{}
if err := c.ShouldBindJSON(&amp;inputMap); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{&quot;error&quot;: &quot;请发送合法的 JSON&quot;})
    return
}
db.DB.WithContext(c.Request.Context()).Model(&amp;book).Updates(inputMap)
</code></pre>

<p>方案二的优势在于：前端传什么就更新什么，不会因为零值问题导致意外行为，且在字段较多的场景下更灵活。</p>

<blockquote>
<p><strong>方案二的代价（两个坑）：</strong></p>

<ul>
<li><strong>JSON 数字会变成 <code>float64</code></strong>：<code>ShouldBindJSON</code> 解析到 <code>map[string]interface{}</code> 时，任何数字键值都是 <code>float64</code>（<code>{&quot;price&quot;:6990}</code> → <code>float64(6990)</code>）。实测（pgx + PostgreSQL）：float64 能写进整数列，整数没问题；遇到非整数值（如 <code>6990.5</code>）<strong>不会报错，而是被静默舍入成 6990</strong>——数据悄悄变了。要点是：<strong>map 里的值失去了 Go 的类型保证</strong>，这正是后面要加白名单的第二个原因；</li>
<li><strong>请求里的任意键都会进 UPDATE SET</strong>：客户端误传的键名如果恰好是模型字段（如 <code>ID</code>、<code>DeletedAt</code>），GORM 会把它们解析成 <code>id</code>、<code>deleted_at</code> 列<strong>真的拼进 SET</strong>——最危险的是条件匹配时<strong>悄悄改写主键或软删除时间戳</strong>，比报错更可怕；只有解析不到任何字段的键（如 <code>Comments</code>）才会原样进 SET 报&rdquo;列不存在&rdquo;。</li>
</ul>

<p>所以 map 方案要配<strong>键白名单</strong>（只挑允许的键进更新）——既挡住多余键，也顺带挡掉上面的类型与主键风险；更类型安全的做法是用指针字段 DTO（<code>*string</code> / <code>*int</code>，见第十章进阶方向「请求 DTO 与指针字段」与<a href="./gorm-gin-dto-batch">《数据工程实战》</a>）。</p>
</blockquote>

<p><strong>测试</strong>：</p>

<pre><code class="language-bash">curl -X PUT http://localhost:8080/books/1 \
  -H &quot;Content-Type: application/json&quot; \
  -d '{&quot;price&quot;:6990}'
</code></pre>

<h2 id="第八章-删除图书">第八章：删除图书</h2>

<h3 id="目标-实现-delete-books-id-接口">目标：实现 <code>DELETE /books/:id</code> 接口</h3>

<p>因为 <code>Book</code> 使用了 <code>gorm.Model</code>，GORM 默认执行<strong>软删除</strong>。这意味着记录不会真正从数据库中移除，只是 <code>deleted_at</code> 字段被设为当前时间，查询时默认被过滤掉。</p>

<pre><code class="language-go">// DeleteBook 软删除图书（DELETE /books/:id）
func DeleteBook(c *gin.Context) {
    id := c.Param(&quot;id&quot;)

    // 执行软删除
    result := db.DB.WithContext(c.Request.Context()).Delete(&amp;models.Book{}, id)
    if result.Error != nil { // 先验错误，再判行数——顺序不能反
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;删除失败&quot;})
        return
    }
    if result.RowsAffected == 0 {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }

    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;删除成功&quot;})
}
</code></pre>

<h3 id="软删除行为解析">软删除行为解析</h3>

<table>
<thead>
<tr>
<th>操作</th>
<th>GORM 行为</th>
</tr>
</thead>

<tbody>
<tr>
<td>第一次 DELETE</td>
<td>设置 <code>deleted_at = NOW()</code>，不再出现在查询中</td>
</tr>

<tr>
<td>再次 DELETE 同一条</td>
<td>由于 <code>deleted_at IS NOT NULL</code>，GORM 找不到记录，<code>RowsAffected == 0</code>，返回&rdquo;图书不存在&rdquo;</td>
</tr>
</tbody>
</table>
<p>注意：GORM 执行的是 <code>UPDATE ... SET deleted_at=NOW() WHERE id=? AND deleted_at IS NULL</code>，条件不匹配时 <code>RowsAffected</code> 为 0，并不会重复软删除。</p>

<blockquote>
<p><strong>注意：</strong> 软删除后，默认的 <code>First</code> / <code>Find</code> 查询会自动加上 <code>deleted_at IS NULL</code> 条件，所以被软删除的记录不会出现在列表中。</p>

<p><strong>⚠️ 唯一约束的坑：</strong> 若某字段带唯一索引（如 <code>ISBN</code>——假设你已经给模型加了该字段与唯一索引；本章 <code>Book</code> 只有 <code>Title</code>/<code>Author</code>/<code>Price</code>），软删除的记录仍占用索引——再次插入相同 ISBN 会违反唯一约束。PostgreSQL 的解法是<strong>部分唯一索引</strong>：只让未删除行参与唯一性。</p>
</blockquote>

<pre><code class="language-sql">CREATE UNIQUE INDEX idx_books_isbn ON books (isbn) WHERE deleted_at IS NULL;
</code></pre>

<h3 id="如果需要查询已删除的记录">如果需要查询已删除的记录</h3>

<pre><code class="language-go">db.DB.WithContext(c.Request.Context()).Unscoped().First(&amp;book, id)
</code></pre>

<h3 id="如果需要物理删除-彻底删除">如果需要物理删除（彻底删除）</h3>

<blockquote>
<p>该函数属于<strong>可选项</strong>。第九章的完整版 <code>main.go</code> 默认注册了它（带「可选」注释）；如果你不想对外开放物理删除，把那一行删掉即可。</p>
</blockquote>

<pre><code class="language-go">// DeleteBookPermanently 物理删除图书（DELETE /books/:id/permanent）
func DeleteBookPermanently(c *gin.Context) {
    id := c.Param(&quot;id&quot;)
    // Unscoped() 绕过软删除，执行物理删除
    result := db.DB.WithContext(c.Request.Context()).Unscoped().Delete(&amp;models.Book{}, id)
    if result.Error != nil { // 先验错误，再判行数（与 DeleteBook 同一套顺序）
        c.JSON(http.StatusInternalServerError, gin.H{&quot;error&quot;: &quot;删除失败&quot;})
        return
    }
    if result.RowsAffected == 0 {
        c.JSON(http.StatusNotFound, gin.H{&quot;error&quot;: &quot;图书不存在&quot;})
        return
    }
    c.JSON(http.StatusOK, gin.H{&quot;message&quot;: &quot;物理删除成功&quot;})
}
</code></pre>

<p><strong>测试</strong>：</p>

<pre><code class="language-bash">curl -X DELETE http://localhost:8080/books/1
</code></pre>

<h2 id="第九章-注册路由">第九章：注册路由</h2>

<h3 id="目标-把所有路由注册到-gin-引擎">目标：把所有路由注册到 Gin 引擎</h3>

<p>更新 <code>main.go</code>：</p>

<pre><code class="language-go">package main

import (
    &quot;context&quot;
    &quot;log&quot;
    &quot;time&quot;

    &quot;gin-demo/db&quot;
    &quot;gin-demo/handlers&quot;
    &quot;gin-demo/models&quot;
    &quot;github.com/gin-gonic/gin&quot;
)

// 给每个请求挂一个超时兜底（第五章进阶）
func requestTimeout(d time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx, cancel := context.WithTimeout(c.Request.Context(), d)
        defer cancel()
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}

func main() {
    // 连接数据库
    db.InitDB()

    // 自动迁移
    if err := db.DB.AutoMigrate(&amp;models.Book{}); err != nil {
        log.Fatal(&quot;迁移失败：&quot;, err)
    }

    r := gin.Default()
    // 请求级超时：慢查询会被 context 中断，走 500（生产可映射 504）
    r.Use(requestTimeout(5 * time.Second))

    // RESTful API 路由
    r.POST(&quot;/books&quot;, handlers.CreateBook)
    r.GET(&quot;/books&quot;, handlers.GetBooks)
    r.GET(&quot;/books/:id&quot;, handlers.GetBook)
    r.PUT(&quot;/books/:id&quot;, handlers.UpdateBook) // 严格 REST 语义下部分更新用 PATCH
    r.DELETE(&quot;/books/:id&quot;, handlers.DeleteBook)
    // 可选：第八章的物理删除示例（默认软删除即可满足需求）
    r.DELETE(&quot;/books/:id/permanent&quot;, handlers.DeleteBookPermanently)

    r.Run(&quot;:8080&quot;)
}
</code></pre>

<h3 id="测试-物理删除示例">测试（物理删除示例）</h3>

<pre><code class="language-bash">curl -X DELETE http://localhost:8080/books/1/permanent
</code></pre>

<blockquote>
<p>完整运行后，除 <code>curl</code> 手动测试外，第八章的「物理删除」接口也可通过上面的路由调用。</p>
</blockquote>

<h2 id="第十章-总结">第十章：总结</h2>

<h3 id="你学到的核心知识">你学到的核心知识</h3>

<table>
<thead>
<tr>
<th>操作</th>
<th>GORM 方法</th>
<th>对应 SQL</th>
</tr>
</thead>

<tbody>
<tr>
<td>创建</td>
<td><code>db.Create(&amp;book)</code></td>
<td><code>INSERT INTO ...</code></td>
</tr>

<tr>
<td>查询所有</td>
<td><code>db.Find(&amp;books)</code></td>
<td><code>SELECT * FROM ...</code></td>
</tr>

<tr>
<td>查询单条</td>
<td><code>db.First(&amp;book, id)</code></td>
<td><code>SELECT * FROM ... WHERE id = ?</code></td>
</tr>

<tr>
<td>更新</td>
<td><code>db.Model(&amp;book).Updates(input)</code></td>
<td><code>UPDATE ... SET ...</code></td>
</tr>

<tr>
<td>软删除</td>
<td><code>db.Delete(&amp;models.Book{}, id)</code></td>
<td><code>UPDATE ... SET deleted_at = NOW()</code></td>
</tr>

<tr>
<td>物理删除</td>
<td><code>db.Unscoped().Delete(&amp;models.Book{}, id)</code></td>
<td><code>DELETE FROM ...</code></td>
</tr>

<tr>
<td>查询已删除</td>
<td><code>db.Unscoped().First(&amp;models.Book{}, id)</code></td>
<td><code>SELECT * FROM ... WHERE id = ?</code>（不限软删除）</td>
</tr>
</tbody>
</table>

<blockquote>
<p>全文的 5 个 handler 都是「先看公式」节六步骨架的实例化：参数 → 绑定 → 存在 → 操作 → 结果 → 响应；所有 db 操作均链上 <code>WithContext(c.Request.Context())</code>，查询用 <code>errors.Is</code> 区分「查无记录」（404）与其它错误（500）。</p>
</blockquote>

<h3 id="进阶方向">进阶方向</h3>

<ul>
<li>事务：<code>db.Transaction()</code></li>
<li>分页与筛选：<code>Where</code> / <code>Order</code> / <code>Offset</code> / <code>Limit</code> 的实战已在<a href="./gorm-gin-media-query">《文件与查询增强实战》</a>覆盖</li>
<li>钩子函数：<code>BeforeCreate</code>、<code>AfterUpdate</code></li>
<li>请求 DTO 与指针字段：<code>*string</code> / <code>*int</code> 替代 <code>map[string]interface{}</code> 的实战已在<a href="./gorm-gin-dto-batch">《数据工程实战》</a>落地（<code>Price *int</code> 就是它）</li>
<li>响应精简：响应键名为什么是大写（<code>ID</code> / <code>CreatedAt</code>），以及想统一风格怎么办——见第三章「三个新手最容易忽略的点」与第五章响应示例的提示（自定义字段声明 <code>json:&quot;-&quot;</code>/小写 tag，或 DTO 做统一响应结构）</li>
<li>连接池：<code>sqlDB, _ := db.DB.DB()</code> 获取底层连接后，用 <code>SetMaxOpenConns()</code> / <code>SetConnMaxLifetime()</code> 配置连接池</li>
<li>工程化沉淀（选读）：Repository / Service 分层与测试（<code>BookRepository</code> + sqlmock）、泛型 <code>GetPaginated[T]</code> 封装——落地见<a href="./gorm-gin-engineering-layering">《工程化（一）》</a>与<a href="./gorm-gin-engineering-reliability">《工程化（二）》</a></li>
<li>超时映射：<code>errors.Is(err, context.DeadlineExceeded)</code> 时返回 504，而不是笼统的 500</li>
<li>超时一行库：想少写代码可用 gin-contrib/timeout 的 <code>timeout.New(...)</code> 接入，但会把 ctx 派生/回写机制变成黑盒，教程保持手写以便看清原理</li>
<li>SQL 调试：开启 GORM 日志 <code>&amp;gorm.Config{Logger: logger.Default.LogMode(logger.Info)}</code>，排查 SQL 问题</li>
<li>统一错误处理：用错误中间件 / 统一响应包装（ok/fail 结构）收敛重复的 500 样板——正文保持显式检查以便看清 <code>errors.Is</code> 的区分逻辑</li>
</ul>

<blockquote>
<p><strong>进阶内容请移步系列正篇：</strong> 关联查询（<code>Preload</code>）、分页聚合等实战在<a href="./gorm-gin-relations">《GORM 多表关联实战》</a>与<a href="./gorm-gin-media-query">《GORM 文件与查询增强实战》</a>；Repository / Service 分层与可测试性落地于<a href="./gorm-gin-engineering-layering">《GORM 工程化实战（一）》</a>与<a href="./gorm-gin-engineering-reliability">《GORM 工程化实战（二）》</a>——正文保持平铺直连，聚焦 GORM 本体。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[从零搭建一个完整的图书管理 API，涵盖 GORM 的 CRUD、软删除、零值陷阱等核心知识点，附带完整代码和测试命令]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:relation><![CDATA[series:gin-gorm]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Godot 4 + C# 踩坑记：五个深坑，附速查表与最小复现]]></title>
      <link>https://moongate.top/docs/godot-csharp-pitfalls</link>
      <guid isPermaLink="true">https://moongate.top/docs/godot-csharp-pitfalls</guid>
      <pubDate>Sun, 23 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>版本环境：Godot 4.7.2（mono 版）/ .NET SDK 9 / C#（net8.0）/ Linux</p>

<p>🎯 本文面向：<strong>已能编译运行 Godot C# 项目、正在为脚本加载/构建问题排查的开发者</strong>。新手建议先跟官方教程跑通 Hello World 再回来看。以下默认你了解 csproj 与终端基本操作；陌生术语的小节可跳过，不影响主线。</p>

<p>🔧 文中折叠块是<strong>源码级深挖</strong>，欢迎展开——那正是本文的硬核内核；主线只看&rdquo;现象 → 解法&rdquo;即可。</p>

<p>📌 本文未覆盖（本项目的开发流程未踩过，<strong>没踩过的不写</strong>，欢迎读者补充）：</p>

<ul>
<li>热重载（Hot Reload）/运行时调试</li>
<li><code>Godot.Collections.Array&lt;T&gt;</code> 等泛型容器限制、<code>StringName</code> 空值行为等社区常见坑</li>
<li>移动端/Web 导出链路上的 C# 坑</li>
</ul>
</blockquote>

<h2 id="速查表-先看这个">🚨 速查表（先看这个）</h2>

<table>
<thead>
<tr>
<th align="left">坑</th>
<th align="left">你做了什么操作</th>
<th align="left">现象关键词</th>
<th align="left">一招解决</th>
</tr>
</thead>

<tbody>
<tr>
<td align="left">1</td>
<td align="left">新建/重命名了一个 C# 脚本</td>
<td align="left">脚本&rdquo;找不到类 / 不继承 Node&rdquo;</td>
<td align="left"><strong>文件名（含大小写）必须与类名完全一致（PascalCase 对齐）</strong></td>
</tr>

<tr>
<td align="left">2</td>
<td align="left">外部编辑器修改了 project.godot</td>
<td align="left">配置被旧副本覆盖、点击全失效</td>
<td align="left"><strong>改 project.godot 前先关编辑器</strong></td>
</tr>

<tr>
<td align="left">3</td>
<td align="left">多次构建 / 编辑器开着时构建</td>
<td align="left">构建报&rdquo;特性重复 CS0579&rdquo;</td>
<td align="left"><strong>关编辑器 + csproj 排除兄弟项目 bin/obj</strong></td>
</tr>

<tr>
<td align="left">4</td>
<td align="left">运行单元测试（xUnit 等）</td>
<td align="left">提示安装或更新 .NET</td>
<td align="left"><strong>测试项目加 <code>&lt;RollForward&gt;LatestMajor&lt;/RollForward&gt;</code></strong></td>
</tr>

<tr>
<td align="left">5</td>
<td align="left">代码里写了 <code>Timer</code> 变量</td>
<td align="left">编译报 Timer 歧义 CS0104</td>
<td align="left"><strong>显式 <code>Godot.Timer</code></strong>，或 <code>&lt;ImplicitUsings&gt;disable&lt;/ImplicitUsings&gt;</code></td>
</tr>
</tbody>
</table>

<h2 id="保命规则-6-条">🛡️ 保命规则（6 条）</h2>

<ol>
<li>C# 文件名 = 类名（PascalCase，区分大小写）</li>
<li>改 <code>project.godot</code> 前先关闭编辑器</li>
<li>编辑器开启时避免 CLI 构建（<strong>避免并发写是关键</strong>——本地最省心是构建前关编辑器；CI 环境无编辑器，构建天然安全；必要时挂起见坑三）</li>
<li>测试/控制台项目声明 <code>&lt;RollForward&gt;LatestMajor&lt;/RollForward&gt;</code></li>
<li>Godot 类型全限定（<code>Godot.Timer</code>），或禁用 ImplicitUsings</li>
<li>多项目根 csproj 排除其他项目的 bin/obj 与 <code>.godot/**</code></li>
</ol>

<h2 id="预防性编码规范-被动排查-主动规避">🛡️ 预防性编码规范（被动排查 → 主动规避）</h2>

<ul>
<li><strong>命名</strong>：C# 脚本一律 PascalCase 且与类名一致；新脚本用编辑器&rdquo;新建脚本&rdquo;模板创建，不手写文件名</li>
<li><strong>配置</strong>：<code>project.godot</code> 只用编辑器内的项目设置面板修改；确需文本编辑时，先关编辑器</li>
<li><strong>构建</strong>：编辑器开启时避免 CLI 构建；构建脚本（CI）里先处理编辑器状态（关闭或挂起）</li>
<li><strong>多项目</strong>：每个根级 csproj 都要排除其他项目的 bin/obj 与 <code>.godot/**</code></li>
<li><strong>命名空间</strong>：Godot 类型与 BCL 撞名时一律全限定，或统一 <code>ImplicitUsings</code> 策略</li>
</ul>

<hr>

<h2 id="一键落地-脚手架三件套-不想读权衡-直接抄这份">🚀 一键落地：脚手架三件套（不想读权衡？直接抄这份）</h2>

<p>想省心：把下面三份文件放进项目根目录即可。想理解原理：看坑三/坑四。</p>

<h3 id="①-directory-build-props">① <code>Directory.Build.props</code></h3>

<pre><code class="language-xml">&lt;!-- 放项目根目录；MSBuild 自动导入到其下所有项目 --&gt;
&lt;Project&gt;
  &lt;PropertyGroup&gt;
    &lt;DefaultItemExcludes&gt;$(DefaultItemExcludes);.godot/**;engine/bin/**;engine/obj/**;tests/**;tools/**&lt;/DefaultItemExcludes&gt;
  &lt;/PropertyGroup&gt;
  &lt;ItemGroup&gt;
    &lt;Compile Remove=&quot;.godot/**&quot; /&gt;
    &lt;Compile Remove=&quot;engine/bin/**&quot; /&gt;
    &lt;Compile Remove=&quot;engine/obj/**&quot; /&gt;
  &lt;/ItemGroup&gt;
&lt;/Project&gt;
</code></pre>

<h3 id="②-build-sh">② <code>build.sh</code></h3>

<pre><code class="language-bash">#!/usr/bin/env bash
set -e
# 1. 检测 Godot 编辑器是否开启（编辑器会自动构建，与 CLI 抢写 .godot/mono）
if pgrep -f &quot;Godot.*--editor&quot; &gt; /dev/null; then
  echo &quot;⚠️ 检测到 Godot 编辑器正在运行，请先关闭再构建（避免并发写）&quot;
  exit 1
fi
# 2. 构建 + 测试
dotnet build tianxing.sln
dotnet test tianxing.sln
</code></pre>

<blockquote>
<p>🔎 <strong>通用检测思路</strong>：进程匹配模式因平台/发行版而异（Steam 版 Godot 的进程名可能不同）。<code>build.sh</code> 中的 <code>&quot;Godot.*--editor&quot;</code> 可改成你自己的模式（如环境变量 <code>GODOT_PROC_PATTERN</code>）；不确定时先自查：<code>ps aux | grep -i godot</code>（Linux/macOS）或 <code>Get-Process | Where-Object {$_.ProcessName -like &quot;*godot*&quot;}</code>（Windows）。核心原则只有一个：<strong>构建时没有其他进程在写 <code>.godot/mono</code></strong>。</p>

<p>🪟 <strong>Windows（PowerShell）</strong>：用 <code>build.ps1</code>——<code>build.sh</code> 仅适用 Linux/macOS：</p>
</blockquote>

<pre><code class="language-powershell"># build.ps1
# 1. 检测 Godot 编辑器进程（自动构建会与 CLI 抢写 .godot/mono）
if (Get-Process -Name &quot;Godot*&quot; -ErrorAction SilentlyContinue) {
    Write-Host &quot;⚠️ 检测到 Godot 编辑器正在运行，请先关闭再构建（避免并发写）&quot;
    exit 1
}
# 2. 构建 + 测试
dotnet build tianxing.sln
dotnet test tianxing.sln
</code></pre>

<h3 id="③-gitignore-补充段">③ <code>.gitignore</code> 补充段</h3>

<pre><code class="language-gitignore">bin/
obj/
.godot/mono/temp/
*.user
</code></pre>

<hr>

<h2 id="坑一-文件名-类名-pascalcase-否则脚本被静默忽略">坑一：文件名 == 类名（PascalCase）——否则脚本被静默忽略</h2>

<p><strong>现象</strong>：autoload 脚本起不来，无头运行同样失败：</p>

<pre><code>ERROR: Failed to instantiate an autoload, script 'res://autoload/game_manager.cs' does not inherit from 'Node'.
</code></pre>

<pre><code>ERROR: Cannot instantiate C# script because the associated class could not be found.
Make sure the script exists and contains a class definition with a name that matches
the filename of the script exactly (it's case-sensitive).
</code></pre>

<p><strong>最小复现</strong>：文件名 <code>game_manager.cs</code>，类名 <code>GameManager</code>（自认为天经地义，实际必挂）：</p>

<pre><code class="language-csharp">// 文件: res://autoload/game_manager.cs
using Godot;

public partial class GameManager : Node { }
</code></pre>

<p>❌ 无效尝试：改了 3 次命名空间（<code>Tianxing.GameManager</code> → <code>Autoload.GameManager</code> → 无命名空间），全失败；用 <code>--verbose</code> 确认程序集正常加载，但类就是找不到。</p>

<details>
<summary>🔧 进阶：根因（源码级）——新手可跳过；平台不支持折叠时会直接展示</summary>

Godot 的源生成器 `ScriptPathAttributeGenerator.cs` 只给"文件名（不含扩展名）== 类名"的类生成 `[ScriptPath]` 注册：

> 人话：文件名与类名不匹配的类，直接在这里被过滤掉，根本不会生成注册代码。

<pre><code class="language-csharp">.Where(x =&gt;
    // Ignore classes whose name is not the same as the file name
    Path.GetFileNameWithoutExtension(x.cds.SyntaxTree.FilePath) == x.symbol.Name)</code></pre>

`game_manager.cs` 配 `GameManager` 类 → 文件名 ≠ 类名 → 类被**静默忽略**，不生成任何注册，运行时自然"找不到类"，且报错信息极具误导性。

补充：若类分散在多个 `partial` 文件中，过滤是按语法树逐个判断的——**只要至少一个文件**的文件名与类名相同，该类就能被注册；其余 partial 文件只是不参与注册生成。**建议：主类文件对齐文件名，其余 partial 文件不要定义与文件名不符的类**（否则排查时极易混淆）。

</details>

<p><strong>解法</strong>：</p>

<pre><code class="language-bash">git mv autoload/game_manager.cs autoload/GameManager.cs   # 文件名对齐类名即可
</code></pre>

<p>✅ <strong>一句话总结</strong>：Godot C# 脚本的文件名（含大小写）必须与类名一字不差，否则脚本被静默丢弃。</p>

<hr>

<h2 id="坑二-改-project-godot-前先关编辑器-否则配置被旧副本覆盖">坑二：改 project.godot 前先关编辑器——否则配置被旧副本覆盖</h2>

<p><strong>现象</strong>：编辑器里 F5 试玩，点击任何东西都没反应；日志只有一行：</p>

<pre><code>ERROR: System.NullReferenceException ... at GameManager.Instance...
</code></pre>

<p><code>git diff project.godot</code> 显示文件被大规模改写：<code>[autoload]</code> 段被删（单例没了，点击自然全失效）、渲染器被改回、窗口尺寸丢失。</p>

<p><strong>最小复现</strong>：</p>

<ol>
<li>打开 Godot 编辑器加载项目</li>
<li>外部修改 <code>project.godot</code>（比如加一个 <code>[autoload]</code> 段）</li>
<li>编辑器侧发生保存（如在项目设置面板 Apply 修改、设置主场景）→ 磁盘文件被旧内存副本覆盖，你的修改消失（注意：仅退出编辑器本身不会写回 project.godot）</li>
</ol>

<p><strong>根因</strong>：编辑器在项目打开期间持有 <code>project.godot</code> 的内存副本；只要编辑器侧发生保存，就以旧副本为准写回——<strong>外部修改被旧内存副本覆盖</strong>。Godot 4.x（4.7.2 实测）在编辑器窗口获焦、检测到文件被外部改动时，通常会弹出 &ldquo;Files have been modified outside Godot&rdquo; 对话框（Reload from disk / Ignore external changes）；<strong>但该提示不拦截后续保存</strong>——无视提示直接进行任何编辑器侧保存（包括在对话框里选 &ldquo;Ignore external changes&rdquo;），外部修改仍会被静默丢弃。仅退出编辑器不会写回 project.godot（与版本控制无关，纯编辑器行为）。</p>

<h3 id="解法-安全修改流程">解法（安全修改流程）</h3>

<ul>
<li>✅ 推荐：在编辑器内通过 <strong>项目设置</strong> 面板修改</li>
<li>✅ 或：关闭编辑器 → 文本编辑器修改 → 重新打开编辑器（此时才会读到新配置）</li>
</ul>

<pre><code class="language-bash"># 关闭编辑器 → 改 project.godot → 重开编辑器
</code></pre>

<p>✅ <strong>一句话总结</strong>：<code>project.godot</code> 只能由编辑器自己改，外部修改必须先关编辑器（否则你的改动会被编辑器内存里的旧副本覆盖）。</p>

<hr>

<h2 id="坑三-cs0579-特性重复-构建污染-需要排除-隔离构建">坑三：CS0579 特性重复——构建污染，需要排除 + 隔离构建</h2>

<p><strong>现象</strong>：</p>

<p><code>dotnet build</code> 稳定报错，指向 <code>.godot/mono/temp/obj/</code> 下的生成文件：</p>

<pre><code>error CS0579: “System.Reflection.AssemblyCompanyAttribute”特性重复
error CS0579: “global::System.Runtime.Versioning.TargetFrameworkAttribute”特性重复
</code></pre>

<p><strong>最小复现</strong>：</p>

<ol>
<li>解决方案根目录同时含一个 Godot 项目（Godot.NET.Sdk，中间产物在 <code>.godot/mono/temp/obj</code>）和一个兄弟项目，如 <code>tests/</code>（普通 Microsoft.NET.Sdk 类库）</li>
<li>构建解决方案 → 兄弟项目的生成文件落在 <code>tests/obj/**</code></li>
<li>再次构建解决方案 → Godot 项目的默认通配符 <code>**/*.cs</code> 把兄弟项目残留的 <code>tests/obj/**/*.cs</code>（生成的 <code>AssemblyInfo.cs</code>、全局 using 等）扫进编译，与 SDK 本次生成的重复 → CS0579，报错指向 <code>.godot/mono/temp/obj/</code></li>
<li>若 Godot 编辑器开着（会自动构建），与 CLI 并发抢写同一目录，局面更糟</li>
</ol>

<details>
<summary>🔧 进阶：根因（源码级）——新手可跳过；平台不支持折叠时会直接展示</summary>

项目的默认 `**/*.cs` 编译通配符收录项目目录下所有 .cs 文件，仅减去 `$(DefaultItemExcludes)` 与隐藏目录。Godot 项目自身的中间产物是安全的：`DefaultItemExcludes` 自动包含 `$(BaseIntermediateOutputPath)/**`——Godot.NET.Sdk 恰好把该属性重定向到 `.godot/mono/temp/obj/`——所以单个 Godot 项目永远不会重复收录自己的生成文件（实测：.NET SDK 5.0–10.0 均含此排除）。

真正的坑在兄弟项目：它们的残留 `bin/`/`obj/` 生成文件（`tests/obj/**` 等）不在 Godot 项目的排除范围内，被扫进其编译后与 SDK 本次生成的程序集特性重复。报错指向 `.godot/mono/temp/obj/` 具有误导性。清缓存治标不治本。

</details>

<h3 id="解法-按安全性排序">解法（按安全性排序）</h3>

<p>① <strong>最稳：关闭编辑器再构建</strong>。没有并发写，没有污染源。</p>

<p>② <strong>CLI 构建前挂起编辑器</strong>（🛠️ 高级技巧，非必需；仅 Linux/macOS）：</p>

<pre><code class="language-bash">pgrep -af Godot          # 找到编辑器 PID
kill -STOP &lt;PID&gt;         # 挂起（冻结）
dotnet build tianxing.sln
kill -CONT &lt;PID&gt;         # 恢复
</code></pre>

<blockquote>
<p>⚠️ <strong>警告</strong>：<code>kill -STOP/CONT</code> 仅限 Linux/macOS，<strong>Windows 不可用</strong>；恢复后若编辑器 UI 异常（渲染/输入卡住），直接重启编辑器即可（项目状态不会丢）。另注意：<strong>挂起时间过长</strong>可能触发 GPU 上下文（Vulkan/OpenGL）在恢复后重置、编辑器崩溃（可能丢编辑器状态，非数据风险）。<strong>核心原则：避免并发写</strong>——本地最省心是构建前关闭编辑器（CI 环境无编辑器，构建天然安全）。若你不需要保留编辑器状态（场景布局、停靠面板等），直接关闭即可；该技巧只在你确实不想重开编辑器时使用。</p>

<p>🪟 <strong>Windows 用户</strong>：建议直接关闭编辑器再构建（最稳），或使用编辑器内构建（F5 会自动构建）；CLI 构建并非 Windows 下的常态路径。</p>
</blockquote>

<p>③ <strong>全局排除：Directory.Build.props</strong>（推荐多项目，最佳实践）：</p>

<pre><code class="language-xml">&lt;!-- 根目录 Directory.Build.props：MSBuild 自动导入到其下所有项目，一次性全局排除 --&gt;
&lt;Project&gt;
  &lt;PropertyGroup&gt;
    &lt;DefaultItemExcludes&gt;$(DefaultItemExcludes);.godot/**;engine/bin/**;engine/obj/**;tests/**;tools/**&lt;/DefaultItemExcludes&gt;
  &lt;/PropertyGroup&gt;
  &lt;ItemGroup&gt;
    &lt;Compile Remove=&quot;.godot/**&quot; /&gt;
    &lt;Compile Remove=&quot;engine/bin/**&quot; /&gt;
    &lt;Compile Remove=&quot;engine/obj/**&quot; /&gt;
  &lt;/ItemGroup&gt;
&lt;/Project&gt;
</code></pre>

<blockquote>
<p>📦 放在<strong>项目根目录</strong>，所有子项目（含 engine/、tests/）自动继承；排除路径相对<strong>各项目自己的根</strong>解析，对不存在的目录无副作用。多项目维护成本更低——新增项目零配置。若只想对单个项目生效，仍用方案 ④ 的 csproj 局部排除（更显式）。</p>
</blockquote>

<p>④ <strong>局部排除：单 csproj 显式配置</strong>（更显式，单项目场景够用）：</p>

<pre><code class="language-xml">&lt;!-- 排除路径相对项目根目录 --&gt;
&lt;PropertyGroup&gt;
  &lt;DefaultItemExcludes&gt;$(DefaultItemExcludes);.godot/**;engine/bin/**;engine/obj/**;tests/**;tools/**&lt;/DefaultItemExcludes&gt;
&lt;/PropertyGroup&gt;
&lt;ItemGroup&gt;
  &lt;Compile Remove=&quot;.godot/**&quot; /&gt;
  &lt;Compile Remove=&quot;engine/bin/**&quot; /&gt;
  &lt;Compile Remove=&quot;engine/obj/**&quot; /&gt;
&lt;/ItemGroup&gt;
</code></pre>

<blockquote>
<p>💡 本项目实测 <code>DefaultItemExcludes</code> 生效；仍推荐与 <code>Compile Remove</code> <strong>双保险</strong>使用（若旧版 Godot.NET.Sdk 未尊重该属性，<code>Compile Remove</code> 兜底）。若排除仍未生效：Sdk 简写语法（<code>&lt;Project Sdk=&quot;...&quot;&gt;</code>）下项目体位置已满足默认项求值顺序；显式 <code>&lt;Import&gt;</code> 风格项目请把该 PropertyGroup 放在 Sdk.props 导入之后。<strong>Godot.NET.Sdk 各版本的排除机制可能有差异，以你所用版本文档为准</strong>（本项目实测 4.7.2 下传统排除生效）。</p>

<p>📦 <strong>多 csproj 项目</strong>：若不用 Directory.Build.props（方案 ③），则每个根级 csproj 需<strong>各自添加</strong>排除——兄弟项目的生成产物（<code>tests/obj/**</code>、<code>engine/obj/**</code> 等）可能被 Godot 项目的默认通配符扫到（<code>.godot/**</code> 本身在当前 .NET SDK 上已作为隐藏目录被默认排除）。</p>

<p>📤 <strong>导出场景</strong>：需要打包时用 <code>godot --headless --export-release &lt;预设&gt;</code>（需先在编辑器中配置导出预设）。注意：headless 导出内部<strong>同样会触发 C# 构建</strong>（走编辑器构建回调），与 GUI 编辑器并发写 <code>.godot/mono</code> 的风险依然存在——<strong>导出前仍建议关闭 GUI 编辑器</strong>。</p>
</blockquote>

<p>（社区还有把 <code>.godot/mono/temp</code> 软链到 <code>/tmp</code> 隔离的方案，我们未实测验证，不作推荐。）</p>

<p>✅ <strong>一句话总结</strong>：多项目解决方案里，兄弟项目的残留 bin/obj 文件被 Godot 项目的编译通配符扫入并重复生成程序集特性（报错误导性地指向 <code>.godot/mono/temp/obj/</code>）；最稳的解法是&rdquo;关编辑器再构建 + csproj 排除兄弟项目目录&rdquo;。</p>

<hr>

<h2 id="坑四-测试宿主缺-net-8-运行时-rollforward-声明">坑四：测试宿主缺 .NET 8 运行时——RollForward 声明</h2>

<p><strong>现象</strong>：测试项目编译通过，一运行就崩：</p>

<pre><code>Testhost process exited with error: You must install or update .NET to run this application.
Framework: 'Microsoft.NETCore.App', version '8.0.0' (x64)
The following frameworks were found: 9.0.19 at [...]
</code></pre>

<p><strong>最小复现</strong>：本机只有 .NET 9 运行时，目标框架 net8.0 的 xUnit 测试项目直接 <code>dotnet test</code>。</p>

<p><strong>根因</strong>：.NET SDK 9 <strong>可以编译</strong> net8.0 目标（编译器前向兼容），但<strong>运行 testhost 需要 net8.0 运行时</strong>；SDK 不会自动前滚到大版本。</p>

<p><strong>解法</strong>：测试 csproj 声明允许前滚：</p>

<pre><code class="language-xml">&lt;PropertyGroup&gt;
  &lt;RollForward&gt;LatestMajor&lt;/RollForward&gt;
&lt;/PropertyGroup&gt;
</code></pre>

<blockquote>
<p>注意：<code>RollForward</code> 只影响<strong>运行时</strong>的版本选择，不改变<strong>编译时</strong>的目标框架（<code>&lt;TargetFramework&gt;net8.0&lt;/TargetFramework&gt;</code> 保持不变）。前滚到 .NET 9 运行存在<strong>极少数 API 行为差异</strong>的风险；开发调试无碍，<strong>生产环境建议部署正确的运行时版本</strong>。</p>

<p>💡 <strong>CI 建议</strong>：在 CI/CD 等关键环境，<strong>最佳实践是用 <code>global.json</code> 固定 SDK 版本 + 安装目标运行时，而非依赖 <code>RollForward</code></strong>——避免隐式前滚行为，防止&rdquo;本地能跑、CI 跑不了&rdquo;，也让行为完全可复现。</p>
</blockquote>

<p>✅ <strong>一句话总结</strong>：目标框架与已装运行时不一致时，给需要运行的项目加 <code>&lt;RollForward&gt;LatestMajor&lt;/RollForward&gt;</code>。</p>

<hr>

<h2 id="坑五-timer-命名冲突-godot-类型全限定">坑五：Timer 命名冲突——Godot 类型全限定</h2>

<p><strong>现象</strong>：</p>

<pre><code>error CS0104: “Timer”是“Godot.Timer”和“System.Threading.Timer”之间的不明确的引用
</code></pre>

<p><strong>最小复现</strong>：C# 项目开启 <code>ImplicitUsings</code>（隐式引入 <code>System.Threading</code>），代码里写 <code>new Timer { ... }</code> 且 <code>using Godot;</code>。</p>

<p><strong>根因</strong>：两个命名空间都有 <code>Timer</code>，编译器无法自动判定。</p>

<h3 id="解法-二选一">解法（二选一）</h3>

<p>① 字段与构造都显式限定：</p>

<pre><code class="language-csharp">private Godot.Timer _timer = null!;
_timer = new Godot.Timer { OneShot = true, WaitTime = 2.0f };
</code></pre>

<p>② 项目里大量使用 <code>System</code> 类型、逐个限定太繁琐时，直接禁用隐式 using 并手动引入：</p>

<pre><code class="language-xml">&lt;PropertyGroup&gt;
  &lt;ImplicitUsings&gt;disable&lt;/ImplicitUsings&gt;
&lt;/PropertyGroup&gt;
</code></pre>

<blockquote>
<p>补充：Godot 4 的 <code>Timer.WaitTime</code> 是 <code>double</code>（秒）；若你实际用的是 <code>System.Timers.Timer</code> 或 <code>System.Threading.Timer</code>，那是另一套 API（回调模型、线程语义都不同），注意区分。</p>
</blockquote>

<p>✅ <strong>一句话总结</strong>：Godot 类型与 BCL 撞名时，显式全限定，或统一禁用 ImplicitUsings。</p>

<hr>

<h2 id="附录一-排查工具-这套问题的定位利器">附录一：排查工具（这套问题的定位利器）</h2>

<ol>
<li><strong><code>godot --headless --verbose</code></strong>：启动日志会打印 .NET 模块初始化、API 哈希、程序集路径——确认&rdquo;程序集到底加载没有&rdquo;最快的方法</li>
<li><strong>直接读 Godot 源码</strong>（官方文档查不到时）：

<ul>
<li><code>modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ScriptPathAttributeGenerator.cs</code> —— 坑一根因（关键过滤在 <code>#L54-L57</code>：<a href="https://github.com/godotengine/godot/blob/4.7.2-stable/modules/mono/editor/Godot.NET.Sdk/Godot.SourceGenerators/ScriptPathAttributeGenerator.cs#L54-L57" target="_blank">blob 链接</a>）</li>
<li><code>modules/mono/glue/GodotSharp/GodotSharp/Core/Bridge/ScriptManagerBridge.cs</code> —— 路径→类型注册机制</li>
<li><code>modules/mono/godotsharp_dirs.cpp</code> / <code>modules/mono/mono_gd/gd_mono.cpp</code> —— 程序集目录与加载逻辑（坑三旁证）</li>
<li>抓取：<code>https://raw.githubusercontent.com/godotengine/godot/&lt;版本tag&gt;/modules/mono/...</code>（release tag 如 <code>4.7.2-stable</code> 固定指向发布时的 commit，可安全引用；如需绝对稳定可自行替换为 commit hash）</li>
</ul></li>
<li><strong><code>pgrep -af Godot</code> + <code>kill -STOP/CONT</code></strong>：处理编辑器并发构建（挂起而非杀进程）</li>
<li><strong>分而治之</strong>：把问题缩小到最小场景（一个脚本、一个场景、一次构建）再定位</li>
</ol>

<h2 id="附录二-遇到奇怪-c-错误的排查顺序">附录二：遇到奇怪 C# 错误的排查顺序</h2>

<p>按顺序检查，五分钟内定位绝大多数问题：</p>

<ol>
<li><strong>文件名 == 类名？</strong>（含大小写）——脚本被静默忽略的最常见原因</li>
<li><strong><code>project.godot</code> 是否被外部改过？</strong>——<code>git diff project.godot</code> 看有无意外回退（autoload/渲染/窗口设置）</li>
<li><strong>构建目录是否被通配符扫入？</strong>——确认 csproj 已排除 <code>.godot/**</code> 与其他项目的 bin/obj</li>
<li><strong>运行时版本是否匹配？</strong>——测试/控制台项目加 <code>RollForward</code>，或安装对应运行时</li>
<li><strong>命名空间冲突？</strong>——Godot 类型与 BCL 撞名时全限定，或统一 ImplicitUsings 策略</li>
</ol>
]]></content:encoded>
      <description><![CDATA[总结 Godot 4 + C# 开发中五个最隐蔽的陷阱（脚本加载、配置覆盖、构建污染、测试运行、命名冲突），附带源码级根因分析、速查表和工程化脚手架，适合已能编译运行的开发者。]]></description>
      <category><![CDATA[Godot]]></category>
      <category><![CDATA[C#]]></category>
      <category><![CDATA[.NET]]></category>
      <category><![CDATA[Compiler]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[Vue 3 组件库的 TypeScript Props 类型导出：一个看似简单的坑]]></title>
      <link>https://moongate.top/docs/vue-component-library-type-export</link>
      <guid isPermaLink="true">https://moongate.top/docs/vue-component-library-type-export</guid>
      <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文基于 moongate-vue 组件库的实际排障经验，完整记录了从 <code>import type { ButtonProps } from 'my-lib'</code> 报错到最终解决方案的全过程。问题跨越 Vue SFC 编译器、TypeScript 模块解析、<code>shims-vue.d.ts</code> 机制和 npm 包发布结构四个知识域。</p>
</blockquote>

<h2 id="引言-一个-不可能-的报错">引言：一个&rdquo;不可能&rdquo;的报错</h2>

<p>在构建 moongate-vue 组件库时，我们在组件内部定义了完善的 Props 类型并成功通过构建：</p>

<pre><code class="language-vue">&lt;!-- Button.vue --&gt;
&lt;script setup lang=&quot;ts&quot;&gt;
export interface ButtonProps {
  label?: string
  variant?: &quot;filled&quot; | &quot;outline&quot;
  size?: &quot;sm&quot; | &quot;md&quot; | &quot;lg&quot;
}
defineProps&lt;ButtonProps&gt;()
&lt;/script&gt;
</code></pre>

<p><code>index.ts</code> 中正常导出：</p>

<pre><code class="language-ts">export type { ButtonProps } from &quot;./components/Button.vue&quot;
</code></pre>

<p><code>pnpm build</code> 一切正常，<code>pnpm run verify:build</code> 28 个组件全部通过。</p>

<p>但当消费者项目使用时：</p>

<pre><code class="language-ts">import type { ButtonProps } from &quot;moongate-vue&quot;
// ❌ 模块 &quot;moongate-vue&quot; 没有导出的成员 &quot;ButtonProps&quot;
</code></pre>

<h3 id="明明构建通过了-为什么消费者拿不到类型">明明构建通过了，为什么消费者拿不到类型？</h3>

<h2 id="排障路径">排障路径</h2>

<p>这是我们在排障过程中走过的完整路径：</p>

<pre><code class="language-text">发现报错（消费者拿不到类型）
    │
    ▼
检查 dist/index.d.ts → 发现 from './components/Button.vue'（.vue 路径）
    │
    ▼
为什么 .vue 路径会导致失败？→ shims-vue.d.ts 的通配符拦截
    │
    ▼
自然方案：把 Props 移到 .ts 文件 → compiler-sfc 报 Unresolvable
    │
    ▼
尝试 vueCompilerOptions.types → 实测两个管道都不支持
    │
    ▼
最终方案：组件内同文件接口 + 独立 .ts 文件双份定义
</code></pre>

<p>接下来我们逐层拆解每个环节。</p>

<hr>

<h2 id="第一层-defineprops-的编译器限制">第一层：<code>defineProps</code> 的编译器限制</h2>

<p>Vue 的 <code>&lt;script setup&gt;</code> 中的 <code>defineProps&lt;T&gt;()</code> 是一个<strong>编译宏</strong>——它不是运行时代码，而是由 <code>@vue/compiler-sfc</code> 在编译阶段解析的。</p>

<p>关键限制：<strong><code>defineProps</code> 的类型参数必须在编译时可解析，且只能引用同文件定义的类型。</strong></p>

<pre><code class="language-vue">&lt;!-- ✅ 可以：同文件定义的类型 --&gt;
&lt;script setup lang=&quot;ts&quot;&gt;
interface Props {
  label?: string
}
defineProps&lt;Props&gt;()
&lt;/script&gt;
</code></pre>

<pre><code class="language-vue">&lt;!-- ❌ 不可以：从外部文件导入的类型 --&gt;
&lt;script setup lang=&quot;ts&quot;&gt;
import type { Props } from &quot;./other-file&quot;
defineProps&lt;Props&gt;() // Unresolvable type reference
&lt;/script&gt;
</code></pre>

<p>这个限制源于 <code>@vue/compiler-sfc</code> 的 <code>resolveTypeElements</code> 实现——它使用了一个<strong>简化的类型解析器</strong>，无法跨文件递归解析复杂的类型引用。当 Props 类型引用了其他模块的类型（如 <code>Component</code>、<code>Size</code> 等），解析器会报 <code>Unresolvable type reference</code>。</p>

<p>这意味着 Props 类型<strong>必须定义在 <code>.vue</code> 文件内</strong>才能被 <code>defineProps</code> 正确解析。但如果我们把 Props 类型放在 <code>.vue</code> 文件里，<code>index.ts</code> 就必须从 <code>.vue</code> 文件 re-export——这就引出了下一层问题。</p>

<hr>

<h2 id="第二层-shims-vue-d-ts-的通配符陷阱">第二层：<code>shims-vue.d.ts</code> 的通配符陷阱</h2>

<p>每个 Vue 3 项目几乎都有一个 <code>shims-vue.d.ts</code>：</p>

<pre><code class="language-ts">// src/shims-vue.d.ts（消费者项目）
declare module &quot;*.vue&quot; {
  import { DefineComponent } from &quot;vue&quot;
  const component: DefineComponent&lt;{}, {}, any&gt;
  export default component
}
</code></pre>

<p>这个文件让 TypeScript 知道 <code>.vue</code> 文件是什么——它声明 <code>*.vue</code> 模块<strong>只导出 <code>default</code></strong>。</p>

<p>当 <code>index.d.ts</code> 中有：</p>

<pre><code class="language-ts">export type { ButtonProps } from &quot;./components/Button.vue&quot;
</code></pre>

<p>TypeScript 解析 <code>./components/Button.vue</code> 这个模块时：</p>

<ol>
<li><strong>如果消费者没有 <code>shims-vue.d.ts</code></strong>：TS 会找到 <code>Button.vue.d.ts</code>（vue-tsc 生成的），里面有 <code>ButtonProps</code>，解析成功 ✅</li>
<li><strong>如果消费者有 <code>shims-vue.d.ts</code></strong>：TS 的模块解析会优先匹配<strong>通配符声明</strong> <code>declare module '*.vue'</code>，而不是查找具体文件。shim 只声明了 <code>export default</code>，没有 <code>ButtonProps</code> → <strong>报错</strong> ❌</li>
</ol>

<p>这是 TypeScript 模块解析的一个反直觉行为：<strong>通配符声明（<code>*.vue</code>）的优先级高于文件路径解析</strong>。几乎所有 Vue 3 项目都需要 <code>shims-vue.d.ts</code>，因此<strong>从 <code>.vue</code> 文件导出具名类型在消费者端几乎一定会失败</strong>。</p>

<pre><code class="language-text">                         ┌─────────────────────────────────┐
                         │      dist/index.d.ts             │
                         │  export type { ButtonProps }     │
                         │  from './components/Button.vue'  │
                         └──────────────┬──────────────────┘
                                        │
                                        ▼
                         ┌─────────────────────────────────┐
                         │  shims-vue.d.ts（消费者项目）      │
                         │  declare module '*.vue' {        │
                         │    export default component       │  ← 只有 default！
                         │  }                                │
                         └──────────────┬──────────────────┘
                                        │
                                        ▼
                               ❌ 没有 ButtonProps
</code></pre>

<hr>

<h2 id="第三层-为什么不能简单地-移到-ts-文件">第三层：为什么不能简单地&rdquo;移到 .ts 文件&rdquo;</h2>

<p>自然的解决方案是：把 Props 类型移到独立的 <code>.ts</code> 文件，这样 <code>index.d.ts</code> 就不引用 <code>.vue</code> 了。</p>

<pre><code class="language-ts">// types/props.ts
export interface ButtonProps {
  label?: string
  variant?: &quot;filled&quot; | &quot;outline&quot;
  size?: &quot;sm&quot; | &quot;md&quot; | &quot;lg&quot;
}
</code></pre>

<pre><code class="language-vue">&lt;!-- Button.vue --&gt;
&lt;script setup lang=&quot;ts&quot;&gt;
import type { ButtonProps } from &quot;../types/props&quot;
defineProps&lt;ButtonProps&gt;()
&lt;/script&gt;
</code></pre>

<p><strong>这看起来完美——但 <code>@vue/compiler-sfc</code> 不允许。</strong></p>

<h3 id="compiler-sfc-的类型解析限制">compiler-sfc 的类型解析限制</h3>

<p>通过最小复现测试确认（使用 vue-tsc 6.0.3 + compiler-sfc 3.5.35 + Vite 8）：</p>

<table>
<thead>
<tr>
<th>场景</th>
<th><code>compiler-sfc</code> (Vite 构建)</th>
<th><code>vue-tsc</code> (类型检查)</th>
</tr>
</thead>

<tbody>
<tr>
<td>同文件 interface</td>
<td>✅ 通过</td>
<td>✅ 通过</td>
</tr>

<tr>
<td>跨文件简单类型（仅 string/number）</td>
<td>✅ 通过</td>
<td>❌ TS2305（注 1）</td>
</tr>

<tr>
<td>跨文件复杂类型（引用 Component/Size）</td>
<td>❌ Unresolvable</td>
<td>❌ TS2305</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注 1</strong>：Vue 3.3+ 官方声明支持 <code>defineProps</code> 引用外部导入的类型。实测中，<code>compiler-sfc</code>（Vite 构建管道）确实能解析跨文件的基础类型。但 <code>vue-tsc</code>（类型检查管道）在带 <code>&quot;types&quot;</code> 字段的 <code>tsconfig.json</code> 下，走的是 <code>@vue/language-core</code> 的 SFC 模块解析路径，对跨文件类型导入<strong>一致地拒绝</strong>。由于真实项目几乎必然配置了 <code>&quot;types&quot;: [&quot;vite/client&quot;, &quot;node&quot;]</code> 等，这个差异在实践中意味着：<strong>消费者端的类型检查始终会失败</strong>。</p>
</blockquote>

<h4 id="结论">结论</h4>

<p><code>defineProps&lt;T&gt;()</code> 的 <code>T</code> <strong>只能引用同文件定义的类型</strong>——即使 <code>compiler-sfc</code> 在构建时能编译通过，<code>vue-tsc</code> 在类型检查阶段也会报错。对于组件库而言，两道关卡都必须通过才算可用。</p>

<h3 id="vuecompileroptions-types-能绕过吗"><code>vueCompilerOptions.types</code> 能绕过吗？</h3>

<p>Vue 3.3+ 支持在 <code>tsconfig.json</code> 中配置：</p>

<pre><code class="language-jsonc">{
  &quot;vueCompilerOptions&quot;: {
    &quot;types&quot;: [&quot;/path/to/types/props.ts&quot;],
  },
}
</code></pre>

<p><strong>实测结论：不能。</strong></p>

<table>
<thead>
<tr>
<th>场景</th>
<th><code>compiler-sfc</code> (Vite 构建)</th>
<th><code>vue-tsc</code> (类型检查)</th>
</tr>
</thead>

<tbody>
<tr>
<td>+ <code>vueCompilerOptions.types</code></td>
<td>❌ 仍然失败</td>
<td>❌ 仍然失败</td>
</tr>
</tbody>
</table>
<p><code>vueCompilerOptions.types</code> 主要影响 IDE 语言服务（Volar/TypeScript 服务器）层面，<strong>不影响</strong> <code>compiler-sfc</code> 的编译管道或 <code>vue-tsc</code> 的类型检查管道。</p>

<h3 id="defineprops-的两种声明模式"><code>defineProps</code> 的两种声明模式</h3>

<p>既然类型声明模式有此限制，Vue 的 <code>defineProps</code> 实际上还有另一种方式：</p>

<table>
<thead>
<tr>
<th>模式</th>
<th>语法</th>
<th>类型解析</th>
<th>适用场景</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>类型声明</strong></td>
<td><code>defineProps&lt;Props&gt;()</code></td>
<td>需要 <code>compiler-sfc</code> 解析类型</td>
<td>简单组件</td>
</tr>

<tr>
<td><strong>运行时声明</strong></td>
<td><code>defineProps({ label: String, ... })</code></td>
<td>不需要类型解析</td>
<td>复杂类型/大型库</td>
</tr>
</tbody>
</table>
<p>运行时声明<strong>不走类型解析管道</strong>，从根本上绕开了限制。Element Plus、Naive UI 等大型组件库都采用这种模式：</p>

<pre><code class="language-ts">// 运行时声明 + ExtractPropTypes
export const buttonProps = {
  label: { type: String, default: &quot;&quot; },
  variant: { type: String, default: &quot;filled&quot; },
} as const

export type ButtonProps = ExtractPropTypes&lt;typeof buttonProps&gt;
</code></pre>

<p>类型定义在纯 <code>.ts</code> 文件中，<code>index.d.ts</code> 不引用 <code>.vue</code>，消费者不会有 shim 问题。<strong>这是目前唯一能彻底避免类型导出问题的方案。</strong></p>

<hr>

<h2 id="我们最终采用的方案">我们最终采用的方案</h2>

<p>考虑到项目的规模（28 个组件）和改动成本，我们采用了<strong>折中方案</strong>：</p>

<h3 id="组件内保留同文件接口-供-defineprops-编译">组件内保留同文件接口（供 <code>defineProps</code> 编译）</h3>

<pre><code class="language-vue">&lt;!-- Button.vue --&gt;
&lt;script setup lang=&quot;ts&quot;&gt;
export interface ButtonProps {
  label?: string
  variant?: &quot;filled&quot; | &quot;outline&quot;
}
defineProps&lt;ButtonProps&gt;()
&lt;/script&gt;
</code></pre>

<h3 id="同时在独立-ts-文件中定义对外导出的类型">同时在独立 .ts 文件中定义对外导出的类型</h3>

<pre><code class="language-ts">// types/props.ts
import type { Component } from &quot;vue&quot;
import type { Size, AddonColor } from &quot;./components&quot;

export interface ButtonProps {
  label?: string
  variant?: &quot;filled&quot; | &quot;outline&quot;
  size?: Size
  icon?: string | Component
}
</code></pre>

<h3 id="index-ts-从-ts-文件导出-不引用-vue">index.ts 从 .ts 文件导出（不引用 .vue）</h3>

<pre><code class="language-ts">export type { ButtonProps } from &quot;./types/props&quot;
</code></pre>

<p>构建后 <code>dist/index.d.ts</code> <strong>0 处 .vue 类型引用</strong>，消费者解析无碍。</p>

<h3 id="代价-双份维护">代价：双份维护</h3>

<p>组件内的 <code>ButtonProps</code>（供 <code>defineProps</code> 编译）和 <code>types/props.ts</code> 中的 <code>ButtonProps</code>（供对外导出）是两份独立的定义，需要手动保持一致。</p>

<h4 id="决策参考">决策参考</h4>

<p>对于 50 个组件以内的库，手写双份的维护成本远低于将整个项目重构为运行时声明（<code>ExtractPropTypes</code>）的改造成本。如果库的规模预期超过 50 个组件，建议从一开始就采用运行时声明方案。</p>

<hr>

<h2 id="给组件库作者的建议">给组件库作者的建议</h2>

<h3 id="发布前的类型检查清单">发布前的类型检查清单</h3>

<ol>
<li><strong>验证 <code>index.d.ts</code> 不含 <code>.vue</code> 类型引用</strong>：</li>
</ol>

<pre><code class="language-bash">   grep -c &quot;\.vue&quot; dist/index.d.ts  # 应为 0（仅注释中允许）
   grep &quot;^import type.*\.vue&quot; dist/index.d.ts  # 应为空
</code></pre>

<ol>
<li><strong>用 <code>shims-vue.d.ts</code> 模拟消费者环境测试</strong>：</li>
</ol>

<pre><code class="language-ts">   declare module &quot;*.vue&quot; {
     const c: DefineComponent&lt;{}, {}, any&gt;
     export default c
   }
</code></pre>

<ol>
<li><p><strong>验证 package.json exports 与组件清单一致</strong>：新增组件后容易遗漏 exports 子路径。</p></li>

<li><p><strong>运行 <code>verify:build</code> 验证</strong>：检查所有 <code>.js</code> 和 <code>.d.ts</code> 产物完整性。</p></li>
</ol>

<h3 id="新建项目推荐">新建项目推荐</h3>

<p>如果你<strong>正在从头构建组件库</strong>，建议直接采用第三层介绍的运行时声明 + <code>ExtractPropTypes</code> 方案——它从根本上避免了所有类型导出问题，且是 Element Plus、Naive UI 等主流库验证过的成熟模式。</p>

<hr>

<h2 id="进阶思考-双份维护的自动化可能">进阶思考：双份维护的自动化可能</h2>

<p>折中方案解决了&rdquo;能不能导出&rdquo;的问题，但引入了双份维护的代价。当前 28 个组件规模尚可接受，扩展到 100+ 时手动同步两份类型定义会成为显著的维护负担。以下两个方向值得探索：</p>

<h3 id="方向一-post-build-自动生成">方向一：Post-build 自动生成</h3>

<p><code>vue-tsc</code> 会为每个组件生成 <code>.vue.d.ts</code> 文件（其中包含完整的 Props 接口定义）。能否写一个构建后脚本，自动从这些 <code>.d.ts</code> 文件中提取 Props 接口，生成 <code>types/props.ts</code>？</p>

<pre><code class="language-js">// scripts/gen-props-types.mjs（概念示例）
import { readdirSync, readFileSync, writeFileSync } from &quot;node:fs&quot;

// 遍历 dist/components/*.vue.d.ts
// 提取 export interface XxxProps { ... } 块
// 写入 src/types/props.ts（或直接更新 dist/types/props.d.ts）
</code></pre>

<p>由于 <code>defineProps</code> 引用的是组件内的同文件类型（编译器能解析），而 <code>.d.ts</code> 产物是准确的类型快照，所以从产物提取可以保证<strong>单一来源</strong>——组件内的接口定义就是权威来源，<code>types/props.ts</code> 只是它的自动生成镜像。</p>

<p>挑战在于：<code>vue-tsc</code> 生成的 <code>.vue.d.ts</code> 是简化后的声明（可能丢失 JSDoc 注释、内部类型别名展开等），需要额外处理才能生成对消费者友好的类型文件。</p>

<h3 id="方向二-vuecompileroptions-types">方向二：<code>vueCompilerOptions.types</code></h3>

<p>Vue 3.3+ 支持在 <code>tsconfig.json</code> 中配置 <code>vueCompilerOptions.types</code>，让 <code>@vue/language-core</code> 在 IDE 语言服务中将指定模块加入 <code>defineProps</code> 的类型解析上下文。</p>

<p>然而正如前文实验所示，<strong>这个选项对 <code>compiler-sfc</code>（Vite 构建）和 <code>vue-tsc</code>（类型检查）都没有帮助</strong>。它只在 IDE 语言服务层面有效，不解决实际构建问题。</p>

<p>除非未来 Vue 工具链统一了这两个管道对跨文件类型解析的支持，否则这个方向的天花板已经很明确。</p>

<h3 id="核心矛盾">核心矛盾</h3>

<p>实验表明：<strong>Vue 编译管道和类型检查管道对外部类型解析的限制是一致的</strong>——两者都不支持 <code>defineProps</code> 引用跨文件导入的类型。这不是某个工具的 bug，而是 Vue SFC 编译宏的设计约束。</p>

<hr>

<h2 id="总结">总结</h2>

<table>
<thead>
<tr>
<th>层级</th>
<th>问题</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td>Vue 编译器</td>
<td><code>defineProps</code> 无法解析跨文件类型（compiler-sfc + vue-tsc 均如此）</td>
<td>Props 类型放同文件（或用运行时声明）</td>
</tr>

<tr>
<td>TS 模块解析</td>
<td><code>.vue</code> 导入被 shims 通配符拦截</td>
<td><code>index.d.ts</code> 不引用 <code>.vue</code> 路径</td>
</tr>

<tr>
<td>构建产物</td>
<td>类型定义需要双份维护</td>
<td>独立 <code>.ts</code> 文件 + 组件内同文件接口（或运行时声明消除双份）</td>
</tr>

<tr>
<td>发布流程</td>
<td>exports 白名单遗漏</td>
<td>verify-build 增加一致性校验</td>
</tr>
</tbody>
</table>
<p>这个问题的本质是 <strong>Vue SFC 编译器</strong> 和 <strong>TypeScript 模块解析</strong> 两个独立系统之间的<strong>契约缺口</strong>——Vue 要求类型在同文件内，而 TS 的 shim 机制会拦截 <code>.vue</code> 路径的具名导出。理解了这个缺口，解决方案就清晰了。</p>
]]></content:encoded>
      <description><![CDATA[深度解析 Vue SFC 编译器与 TypeScript 模块解析之间的契约缺口，揭示 `export type { ButtonProps } from './Button.vue'` 在消费者项目中失败的完整原因及解决方案。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[TypeScript]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[MCP stdio 协议的 3 个隐秘陷阱：当单元测试全绿，但 MCP Server 无法工作]]></title>
      <link>https://moongate.top/docs/mcp-stdio-traps</link>
      <guid isPermaLink="true">https://moongate.top/docs/mcp-stdio-traps</guid>
      <pubDate>Sun, 16 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文记录了一次真实的 MCP Server 调试经历：<code>story-cli</code> 的自动化测试全部通过，但 MCP Server 在真实环境中完全无法响应任何请求。最终排查出 3 个 Bug，每一个都涉及 Node.js 进程模型与 stdio 协议的底层细节。</p>
</blockquote>

<hr>

<h2 id="tl-dr">TL;DR</h2>

<p>如果你正在开发 MCP Server（或者任何基于 stdio 协议的长期运行进程），请记住三条铁律：</p>

<ol>
<li><strong>永远不要在 <code>run()</code> 函数中调用 <code>process.exit()</code></strong> —— MCP Server、<code>--watch</code> 模式等任何长期运行的命令都不是一次性 CLI 工具。<code>process.exit()</code> 会在你开始监听之前就把进程杀掉。如果不得不豁免，请提炼「长期运行」的抽象（如 <code>isLongRunning</code>），而不是枚举具体命令。</li>
<li><strong>永远不要在 stdout 上打印任何调试日志</strong> —— stdout 是 MCP 协议通道，任何非 JSON-RPC 的输出都会污染消息流，导致客户端无法解析任何响应。诊断信息请走 stderr。</li>
<li><strong>永远在 <code>close</code> 事件中等待所有异步操作完成</strong> —— <code>close</code> 只代表输入流关闭，不代表你的回调已执行完毕。你需要在退出前等待所有 in-flight 的 Promise 结束。</li>
</ol>

<hr>

<h2 id="背景-story-cli-的-mcp-server">背景：story-cli 的 MCP Server</h2>

<p>先介绍一下这个项目。<code>story-cli</code> 是一个<strong>零部署、Git 原生的 Markdown 内容管理 CLI</strong>。它用简单的目录约定（<code>NN-名称/</code> 包含 <code>config.json</code> + <code>text.md</code>）管理故事/论文/笔记/教程，自动生成 README，导出 EPUB，中英双语。</p>

<p>在我们的 ROADMAP 中，<strong>MCP Server 是 P0 级战略任务</strong>——AI 时代的入口。设计原则是：<strong>&ldquo;AI 只负责思考，CLI 负责治理&rdquo;</strong>。</p>

<p>我们通过 JSON-RPC 2.0 over stdio 协议暴露了 6 个工具：</p>

<table>
<thead>
<tr>
<th>MCP 工具</th>
<th>功能</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>scan_stories</code></td>
<td>列出所有故事及元数据</td>
</tr>

<tr>
<td><code>read_chapter</code></td>
<td>读取指定故事的章节内容</td>
</tr>

<tr>
<td><code>write_chapter</code></td>
<td>将正文写入指定故事（原子写入）</td>
</tr>

<tr>
<td><code>validate</code></td>
<td>校验所有故事的 config.json 合法性</td>
</tr>

<tr>
<td><code>build</code></td>
<td>触发 README 重建</td>
</tr>

<tr>
<td><code>import_json</code></td>
<td>从结构化 JSON 批量导入故事</td>
</tr>
</tbody>
</table>
<p>代码结构非常干净：</p>

<pre><code class="language-text">src/mcp/
├── protocol.ts   # JSON-RPC 2.0 协议解析/序列化（纯函数，有完整测试）
├── tools.ts      # MCP 工具注册（复用 core/loader.ts 共享逻辑）
└── server.ts     # stdio 服务器启动与请求分发
</code></pre>

<p>一切看起来都很完美——<strong>直到我们真正去调用它</strong>。</p>

<hr>

<h2 id="现象-自动化测试全绿-但真实请求无响应">现象：自动化测试全绿，但真实请求无响应</h2>

<p>我们当时有 <strong>404 个自动化测试，401 通过</strong>。其中 <code>tests/mcp.test.ts</code> 覆盖了协议解析、序列化、工具注册、所有工具的 handler——<strong>全部通过</strong>。</p>

<p>于是我在真实的故事仓库中启动 MCP Server，通过管道发送 JSON-RPC 请求：</p>

<pre><code class="language-bash">echo '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}' | node bin/index.ts mcp-server
</code></pre>

<p>💀 <strong>输出为空。</strong> 没有任何响应。</p>

<p>我以为是我的管道写法有问题。换了好几种方式：</p>

<pre><code class="language-bash"># 方式 1：printf
printf '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}\n' | node bin/index.ts mcp-server

# 方式 2：文件重定向
printf '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}\n' &gt; /tmp/req.json &amp;&amp; node bin/index.ts mcp-server &lt; /tmp/req.json

# 方式 3：保持 stdin 打开
{ printf '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}\n'; sleep 2; } | node bin/index.ts mcp-server
</code></pre>

<p><strong>全部无响应。</strong></p>

<p>更诡异的是，通过 Node.js 的 <code>spawnSync</code> 发送请求时，进程的退出码是 0（看起来&rdquo;成功了&rdquo;），但 stdout 和 stderr 都是空白。</p>

<p>那一刻我意识到：<strong>这不是调用方式的问题，是我们的 MCP Server 有 Bug。</strong></p>

<p>但 404 个测试全绿啊！怎么会有 Bug？</p>

<hr>

<h2 id="bug-1-process-exit-的幽灵">Bug #1：process.exit() 的幽灵</h2>

<h3 id="根因排查">根因排查</h3>

<p>我先去看 CLI 的入口文件 <code>bin/index.ts</code>：</p>

<pre><code class="language-typescript">#!/usr/bin/env node
import { run } from &quot;../src/cli.ts&quot;

const exitCode = await run(process.argv)
process.exit(exitCode)
</code></pre>

<p>问题一目了然。</p>

<p>当用户执行 <code>story mcp-server</code> 时：</p>

<ol>
<li><code>run(process.argv)</code> 被调用</li>
<li><code>run()</code> 内部调用 <code>runMcpServer(rootDir)</code> → 调用 <code>startMcpServer()</code> → 开始监听 stdin</li>
<li><strong><code>run()</code> 立即返回 0</strong>（因为 <code>startMcpServer()</code> 是&rdquo;注册完监听器就返回&rdquo;的异步模式，不会 block）</li>
<li><strong><code>process.exit(0)</code> 立即执行</strong> → 进程终止</li>
<li>stdin 中的 JSON-RPC 请求还没来得及被 readline 读取</li>
</ol>

<p><strong>MCP Server 刚出生就死了。</strong></p>

<p><strong>修复</strong>：</p>

<pre><code class="language-typescript">#!/usr/bin/env node
import { run } from &quot;../src/cli.ts&quot;

const exitCode = await run(process.argv)

// MCP server 需要保持进程存活持续监听 stdin
// 进程退出由 server.ts 内部的 close/SIGINT 事件处理
if (process.argv[2] !== &quot;mcp-server&quot; &amp;&amp; process.argv[2] !== &quot;mcp&quot;) {
  process.exit(exitCode)
}
</code></pre>

<blockquote>
<p>⚠️ <strong>注意</strong>：这个修复方案当时看起来没问题，但后来在同一天的测试中暴露了它的<strong>局限性</strong>——见下方的「Bug #1.5：同源 Bug 复发」。</p>
</blockquote>

<p><strong>深层教训</strong>：这是 CLI 工具转服务化时的<strong>第一坑</strong>：</p>

<table>
<thead>
<tr>
<th>模式</th>
<th>生命周期</th>
<th>退出时机</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>CLI 工具</strong></td>
<td>执行完命令就退出</td>
<td><code>process.exit(exitCode)</code> 是正确做法</td>
</tr>

<tr>
<td><strong>长期运行进程</strong>（MCP Server / 守护进程）</td>
<td>持续监听输入直到 EOF/信号</td>
<td>退出必须由<strong>输入源</strong>触发的回调控制</td>
</tr>
</tbody>
</table>
<p><code>process.exit()</code> 是无条件的、立即的、不可中断的。它不会等待 pending 的 IO、定时器或 Promise。在 MCP Server 的场景下，这个&rdquo;特性&rdquo;直接杀死了我们的 server。</p>

<hr>

<h2 id="bug-1-5-同源-bug-复发-process-exit-的第二次幽灵">Bug #1.5：同源 Bug 复发——process.exit() 的第二次幽灵</h2>

<h3 id="现象">现象</h3>

<p>修复 Bug #1 后，我继续对 MCP Server 做测试。当天，我顺便想验证 <code>story build --watch</code> 的性能表现：</p>

<pre><code class="language-bash">story build --watch
</code></pre>

<p>输出显示「👀 监听模式已启动，文件变更自动重建&hellip;」，但 <strong>进程立刻退出</strong>——<code>--watch</code> 模式根本没有开始监听文件变更。</p>

<p>我尝试修改一个故事文件：</p>

<pre><code class="language-bash">echo &quot;新内容&quot; &gt; &quot;01-测试故事/text.md&quot;
</code></pre>

<p>什么也没有发生。README 文件没有任何更新。</p>

<h3 id="根因-枚举命令的白名单缺陷">根因：枚举命令的白名单缺陷</h3>

<p>我回头看 <code>bin/index.ts</code> 的修复代码：</p>

<pre><code class="language-typescript">if (process.argv[2] !== &quot;mcp-server&quot; &amp;&amp; process.argv[2] !== &quot;mcp&quot;) {
  process.exit(exitCode)
}
</code></pre>

<p>这个逻辑的表述是：<strong>「除了 mcp-server 和 mcp 之外，其他命令都执行 <code>process.exit()</code>」</strong>。</p>

<p>但 <code>build --watch</code> 同样是<strong>长期运行的进程</strong>！它需要持续监听文件变更，直到收到 <code>SIGINT</code>。而这里只豁免了 MCP Server 两个命令——<code>build --watch</code> 不在白名单里，一样会被 <code>process.exit()</code> 立即杀死。</p>

<p><strong>MCP Server 的第一次 bug 修好了，同样的幽灵在 <code>build --watch</code> 上再次出现。</strong></p>

<h3 id="修复-提炼-长期运行-这个抽象">修复：提炼「长期运行」这个抽象</h3>

<p>正确的修复不是继续枚举更多命令，而是提炼出「<strong>哪些命令是长期运行的</strong>」这个本质属性：</p>

<pre><code class="language-typescript">#!/usr/bin/env node
import { run } from &quot;../src/cli.ts&quot;

const exitCode = await run(process.argv)

// 长期运行的进程需要保持存活，进程退出由内部 close/SIGINT 事件处理：
// - MCP server：持续监听 stdin，退出由 server.ts 的 close/SIGINT 控制
// - build --watch：持续监听文件变更，退出由 build.ts 的 SIGINT 控制
const isLongRunning =
  process.argv[2] === &quot;mcp-server&quot; ||
  process.argv[2] === &quot;mcp&quot; ||
  (process.argv[2] === &quot;build&quot; &amp;&amp; process.argv[3] === &quot;--watch&quot;) ||
  (process.argv[2] === &quot;b&quot; &amp;&amp; process.argv[3] === &quot;--watch&quot;)

if (!isLongRunning) {
  process.exit(exitCode)
}
</code></pre>

<h3 id="深层教训-修复-bug-要提炼-抽象-而非枚举-实例">深层教训：修复 Bug 要提炼「抽象」，而非枚举「实例」</h3>

<p>这是本次调试中<strong>最大的反思</strong>：</p>

<table>
<thead>
<tr>
<th>修复方式</th>
<th>代码形态</th>
<th>问题</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>枚举实例</strong>（当时）</td>
<td><code>if (cmd !== &quot;mcp-server&quot; &amp;&amp; cmd !== &quot;mcp&quot;)</code></td>
<td>新加一个长期运行命令就得回来改这行</td>
</tr>

<tr>
<td><strong>提炼抽象</strong>（最终）</td>
<td><code>const isLongRunning = ...</code></td>
<td>任何新命令只需在这个集合里表达自己的属性</td>
</tr>
</tbody>
</table>
<p>当代码中出现「排除列表」（<code>if (cmd !== &quot;A&quot; &amp;&amp; cmd !== &quot;B&quot;)</code>）时，说明你在<strong>枚举具体命令</strong>，而不是表达「哪些命令是长期运行的」这个<strong>本质属性</strong>。一旦有新的长期运行命令出现（比如 <code>--watch</code>），同样的 bug 就会复发。</p>

<h4 id="检查清单">检查清单</h4>

<p>如果你的 CLI 未来要加任何「持续监听」功能（watch / serve / daemon），第一时间检查 <code>bin/index.ts</code> 的 <code>isLongRunning</code> 列表——它必须包含新命令。</p>

<hr>

<h2 id="bug-2-console-log-的致命污染">Bug #2：console.log 的致命污染</h2>

<h3 id="惊喜-修好-bug-1-后出现了部分响应">惊喜：修好 Bug #1 后出现了部分响应</h3>

<p>修复了 Bug #1 后，我惊喜地发现 <code>tools/list</code> 开始有响应了！但有响应的是：</p>

<ul>
<li><code>tools/list</code> ✅</li>
<li><code>initialize</code> ✅</li>
<li>未知工具的错误响应 ✅</li>
</ul>

<p>而 <strong>异步的 <code>tools/call</code> 仍然无响应</strong>（<code>scan_stories</code> / <code>read_chapter</code> / <code>validate</code>）。</p>

<p>我单独测试 <code>scan_stories</code>：</p>

<pre><code class="language-bash">echo '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:3,&quot;method&quot;:&quot;tools/call&quot;,&quot;params&quot;:{&quot;name&quot;:&quot;scan_stories&quot;,&quot;arguments&quot;:{}}}' | node bin/index.ts mcp-server
</code></pre>

<p>还是空。</p>

<p>我换了个思路——直接在 Node 环境中调用 <code>loadStories()</code>：</p>

<pre><code class="language-bash">node --experimental-strip-types -e &quot;
import { loadStories } from './src/core/loader.ts';
const { stories } = await loadStories('/tmp/test-story-cli');
console.log('STORIES:', stories.length);
&quot;
</code></pre>

<p>输出：</p>

<pre><code class="language-text">📊 01-测试故事: 自动计算字数为 约 13 字（未写回，使用 --save-counts 持久化）
📊 02-二创故事: 自动计算字数为 约 13 字（未写回，使用 --save-counts 持久化）
📊 03-English-Story: 自动计算字数为 ~7 words（未写回，使用 --save-counts 持久化）
STORIES: 3
</code></pre>

<p><strong>找到了！</strong> <code>loadStories()</code> 内部用 <code>console.log</code> 输出了&rdquo;自动计算字数&rdquo;的诊断日志。</p>

<h3 id="为什么某个-console-log-就能杀死-mcp">为什么某个 <code>console.log</code> 就能杀死 MCP？</h3>

<p>MCP 的 stdio 传输规范是 <strong>stdout 是协议专用通道</strong>：</p>

<pre><code class="language-text">├── stdin ← 客户端发送 JSON-RPC 请求
├── stdout → 服务器返回 JSON-RPC 响应（协议专用，唯一合法输出）
└── stderr → 日志/警告/错误（人看的，不是协议看的）
</code></pre>

<p>当 MCP 客户端发送 <code>scan_stories</code> 请求，MCP Server 处理时先调用了 <code>loadStories()</code>，<code>console.log</code> 往 stdout 吐了一行 <code>📊 01-测试故事: ...</code> 日志。此时 stdout 变成了：</p>

<pre><code class="language-text">📊 01-测试故事: 自动计算字数为 约 13 字...      ← 污染！
{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:3,&quot;result&quot;:{...}}        ← 真正的响应
</code></pre>

<p>MCP 客户端（VSCode / Claude Desktop / Cursor）在解析 stdout 时，期望每一行都是合法的 JSON-RPC 消息。结果第一行根本不是 JSON——</p>

<p><strong>客户端直接放弃解析，表现为&rdquo;无响应&rdquo;。</strong></p>

<p>顺带一提，MCP 的 stdio 传输还有一个换行符的硬性要求：<strong>每条 JSON-RPC 消息必须以 <code>\n</code>（换行符）结尾</strong>。如果你的服务器输出了一条不带换行的 JSON，客户端也会解析失败。这就是为什么 MCP 官方文档的 Debugging 页面明确指出：</p>

<blockquote>
<p><em>&ldquo;Local MCP servers should not log messages to stdout (standard out), as this will interfere with protocol operation.&rdquo;</em></p>
</blockquote>

<p>——官方早就警告过，但我们直到真实环境踩坑才真正理解这句话。</p>

<p>而且这种 Bug 特别隐蔽：</p>

<ul>
<li>单测环境中，<code>scan_stories</code> 的 handler 被直接调用，stdout 内容没人解析 → 测试通过</li>
<li>真实环境中，MCP 客户端严格解析 stdout → 立刻崩溃</li>
</ul>

<p><strong>修复</strong>：</p>

<pre><code class="language-typescript">// 修复前
if (!config.wordCount) {
  console.log(locale.autoWordCount(folder, story.wordCount, saveCounts))
}
</code></pre>

<pre><code class="language-typescript">// 修复后
if (!config.wordCount) {
  // 使用 stderr 输出诊断信息，避免污染 MCP stdio 协议的 stdout 通道
  console.error(locale.autoWordCount(folder, story.wordCount, saveCounts))
}
</code></pre>

<p>同时 <code>loadStoryContentAsync</code> 中的 <code>console.log(locale.generatedText(...))</code> 也一并改掉。</p>

<p><strong>深层教训</strong>：<strong>stdio 协议中的 stdout 不是给你打日志的。</strong> 它是两个进程之间的协议通道。任何额外的输出——哪怕是看起来无害的一行日志——都会导致协议解析失败。</p>

<p>这是一个<strong>运行时静默失败</strong>的问题：代码不会抛异常，测试不会失败，只有真实客户端会&rdquo;莫名其妙&rdquo;不工作。</p>

<blockquote>
<p>在 MCP Server 中，<code>stdout = 协议</code>，<code>stderr = 日志</code>。永远不要混用。</p>
</blockquote>

<hr>

<h2 id="bug-3-readline-close-的异步竞态">Bug #3：readline close 的异步竞态</h2>

<h3 id="又一个意外">又一个意外</h3>

<p>修复了 Bug #2 后，我以为一切搞定了。但测试发现 <code>tools/call</code> 仍然有<strong>概率性</strong>无响应：有时能收到响应，有时不行。</p>

<p>我盯着 <code>src/mcp/server.ts</code> 的旧代码思考：</p>

<pre><code class="language-typescript">export function startMcpServer(rootDir: string, tools: RegisteredTool[]): void {
  const rl = createInterface({ input: process.stdin, terminal: false })

  rl.on(&quot;line&quot;, async (line) =&gt; {
    // ... 解析并处理请求
    const response = await handleRequest(request, rootDir, tools)
    if (response) process.stdout.write(serializeMessage(response))
  })

  rl.on(&quot;close&quot;, () =&gt; {
    // 等待 stdout 刷新后再退出（避免输出被截断）
    process.stdout.write(&quot;&quot;, () =&gt; process.exit(0))
  })
  // ...
}
</code></pre>

<p>在管道模式下（<code>echo '...' | node bin/index.ts mcp-server</code>），stdin 在读入所有行后立即关闭，触发 <code>close</code> 事件。<strong><code>close</code> 触发时，<code>rl.on(&quot;line&quot;)</code> 中的异步 <code>await handleRequest()</code> 还没执行完！</strong></p>

<p>时序是这样的：</p>

<pre><code class="language-text">时间 t0:  stdin 收到 JSON-RPC 请求行
时间 t1:  rl 触发 &quot;line&quot; 事件，进入 async 回调
时间 t2:  async 回调遇到 await handleRequest()，挂起（黄色区域 = 等待异步结果）
时间 t3:  stdin 读完所有行 → 触发 rl &quot;close&quot; 事件
时间 t4:  &quot;close&quot; 回调执行 process.stdout.write(&quot;&quot;, () =&gt; process.exit(0))
时间 t5:  进程退出，await handleRequest() 还没恢复 → 响应永远丢失
</code></pre>

<p>这就是<strong>异步竞态</strong>：<code>close</code> 通知&rdquo;输入流已关闭&rdquo;，但它不等你的 Promise 完成。</p>

<p><strong>修复</strong>：用 <code>pending</code> Set 跟踪所有 in-flight 请求，在 <code>close</code> 时等待它们全部完成再退出：</p>

<pre><code class="language-typescript">export function startMcpServer(rootDir: string, tools: RegisteredTool[]): void {
  const rl = createInterface({ input: process.stdin, terminal: false })
  const pending = new Set&lt;Promise&lt;void&gt;&gt;()

  rl.on(&quot;line&quot;, (line) =&gt; {
    const trimmed = line.trim()
    if (!trimmed) return
    let request: JsonRpcRequest
    try {
      request = parseRequest(trimmed)
    } catch (e) {
      const code =
        (e as Error &amp; { code?: number }).code ?? JsonRpcErrorCode.InternalError
      process.stdout.write(
        serializeMessage(makeErrorResponse(null, code, (e as Error).message)),
      )
      return
    }
    // 跟踪 in-flight 请求，确保 stdin 关闭时异步 handler 已完成
    const task = (async () =&gt; {
      const response = await handleRequest(request, rootDir, tools)
      if (response) process.stdout.write(serializeMessage(response))
    })()
    pending.add(task)
    task.finally(() =&gt; pending.delete(task))
  })

  rl.on(&quot;close&quot;, () =&gt; {
    // 等待所有 in-flight 请求完成后刷新 stdout 再退出（避免输出被截断）
    void Promise.allSettled([...pending]).then(() =&gt; {
      process.stdout.write(&quot;&quot;, () =&gt; process.exit(0))
    })
  })
  process.on(&quot;SIGINT&quot;, () =&gt; {
    rl.close()
  })
}
</code></pre>

<p><strong>深层教训</strong>：在 Node.js 的事件循环中，<strong><code>readline</code> 的 <code>close</code> 事件只代表&rdquo;输入流关闭&rdquo;，不代表&rdquo;你的异步回调已执行&rdquo;</strong>。</p>

<p>这是所有 stdio 协议服务器的通用问题：stdin EOF 到达时，你可能仍然有 queued 的 Promise。你需要显式地跟踪和等待它们：</p>

<ol>
<li>用一个集合维护所有 in-flight 操作</li>
<li>在 <code>close</code> 或 <code>SIGINT</code> 时用 <code>Promise.allSettled</code> 等待</li>
<li>然后再执行 <code>process.exit</code></li>
</ol>

<hr>

<h2 id="启发-测试的分层">启发：测试的分层</h2>

<p>这次调试给我最大的启发是<strong>测试的分层价值</strong>：</p>

<table>
<thead>
<tr>
<th>测试层级</th>
<th>我们之前的覆盖</th>
<th>发现的问题</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>单元测试</strong>（直接调用 handler 函数）</td>
<td>✅ 401 个全绿</td>
<td>无法发现 Bug #1 / #2 / #3</td>
</tr>

<tr>
<td><strong>集成测试</strong>（调用 <code>startMcpServer</code> 但不走真实进程）</td>
<td>❌ 没有</td>
<td>—</td>
</tr>

<tr>
<td><strong>端到端测试</strong>（spawnSync 真实子进程 + 真实 stdin/stdout）</td>
<td>❌ 没有</td>
<td>一次性暴露全部 3 个 Bug</td>
</tr>
</tbody>
</table>
<p><strong>单元测试全绿不代表系统可用。</strong> 你需要在真正的进程中启动 server，通过真正的管道发送请求，解析真正的 stdout——因为只有端到端测试能捕捉&rdquo;进程生命周期&rdquo;和&rdquo;协议完整性&rdquo;这两个层面的问题。</p>

<pre><code class="language-typescript">// tests/mcp-server.test.ts（我们新增的端到端测试）
function sendRequests(dir: string, requests: string[]) {
  const input = `${requests.join(&quot;\n&quot;)}\n`
  const result = spawnSync(process.execPath, [binPath, &quot;mcp-server&quot;], {
    cwd: dir,
    input,
    encoding: &quot;utf-8&quot;,
    timeout: 5000,
  })
  return {
    stdout: result.stdout || &quot;&quot;,
    stderr: result.stderr || &quot;&quot;,
    status: result.status ?? -1,
  }
}

test(&quot;MCP server 能响应异步 tools/call（scan_stories）&quot;, () =&gt; {
  const { stdout, stderr } = sendRequests(dir, [
    '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:3,&quot;method&quot;:&quot;tools/call&quot;,&quot;params&quot;:{&quot;name&quot;:&quot;scan_stories&quot;,&quot;arguments&quot;:{}}}',
  ])
  // stderr 不应包含 JsonRpcResponse 内容 → 防止 console.log/stdout 污染
  assert.ok(!stderr.includes(&quot;jsonrpc&quot;))

  // 按行分割 + 过滤空行，而不是直接 JSON.parse(stdout.trim())。
  // 如果 stdout 混入了多行输出，trim() 只去首尾空白，中间换行会导致 JSON.parse 失败。
  const lines = stdout
    .split(&quot;\n&quot;)
    .map((l) =&gt; l.trim())
    .filter(Boolean)
  assert.ok(lines.length &gt;= 1, &quot;应至少有一条 JSON-RPC 响应&quot;)
  // 取最后一条（如果请求了多个响应，也可以按 id 查找对应行）
  const response = JSON.parse(lines[lines.length - 1] ?? &quot;{}&quot;)
  // ...
})
</code></pre>

<p>这个测试会在<strong>真实子进程</strong>中启动 MCP Server，通过<strong>真正的管道</strong>发送 JSON-RPC 请求，并验证 stdout 的内容。如果未来有人往 <code>loadStories</code> 加一个 <code>console.log</code>，这个测试会立即失败。</p>

<blockquote>
<p><strong>后续验证（同日）</strong>：Bug #1.5 修复后，我们补上了 <code>build --watch</code> 的端到端回归测试（<code>tests/watch.test.ts</code>）——用 spawnSync 启动真实子进程，断言「进程应保持存活」+「修改故事后 README 在 5 秒内被重建」。如果当时就有这个测试，Bug #1.5 在修复后当天就能被发现，而不是等到后续性能测试时偶然暴露。这正是&rdquo;测试分层&rdquo;的又一次验证：<strong>单元测试无法覆盖进程生命周期，只有端到端测试可以。</strong></p>
</blockquote>

<hr>

<h2 id="附-1-完整的排查流程-供参考">附 1：完整的排查流程（供参考）</h2>

<pre><code class="language-bash"># 1. 创建测试仓库
mkdir -p /tmp/test-story-cli &amp;&amp; cd /tmp/test-story-cli
node /path/to/story-cli/bin/index.ts init
node /path/to/story-cli/bin/index.ts new &quot;测试故事&quot;

# 2. 启动 MCP Server（发现问题）
echo '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}' | node /path/to/story-cli/bin/index.ts mcp-server
# → 空输出（Bug #1）

# 3. 修复 #1 后 → tools/list 有响应，但 scan_stories 无响应（Bug #2 的 stdio 污染）

# 4. 单独验证 loadStories 的行为
node --experimental-strip-types -e &quot;
import { loadStories } from './src/core/loader.ts';
await loadStories('/tmp/test-story-cli');
&quot;
# → 看到 📊 日志出现在 stdout

# 5. 修复 #2 后 → 有时有响应有时没（Bug #3 的异步竞态）

# 6. 通过端到端测试反复验证
node --test tests/mcp-server.test.ts
# → 7 tests pass
</code></pre>

<h2 id="附-2-补充调试工具-mcp-inspector">附 2：补充调试工具 MCP Inspector</h2>

<p>以上是&rdquo;事后排查&rdquo;的思路。如果你在开发阶段就接入 <strong><a href="https://github.com/modelcontextprotocol/inspector" target="_blank">MCP Inspector</a></strong>（MCP 官方调试工具），很多问题可以在发布前被提前发现：</p>

<pre><code class="language-bash">npx @modelcontextprotocol/inspector node /path/to/story-cli/bin/index.ts mcp-server
</code></pre>

<p>MCP Inspector 会启动一个可视化 Web 界面，让你：</p>

<ul>
<li><strong>查看所有工具列表 / 参数 schema</strong>（发现注册问题）</li>
<li><strong>逐个调用工具并观察原始响应</strong>（发现 stdout 污染）</li>
<li><strong>检查协议层通信日志</strong>（发现握手失败 / 换行符问题）</li>
</ul>

<p>它是 MCP Server 开发的&rdquo;X 光机&rdquo;——推荐所有 MCP Server 开发者在 CI/CD 前先过一遍 Inspector。</p>

<blockquote>
<p>社区还有一些第三方辅助工具（如 <code>mcp-stdio-guard</code> 用于捕获 stdout 污染），但 Inspector 作为官方工具足以覆盖大部分场景。</p>
</blockquote>

<h2 id="附-3-ai-交互层的格式漂移">附 3：AI 交互层的格式漂移</h2>

<p>MCP Server 不仅要处理协议陷阱（#1 / #2 / #3），还要处理 <strong>AI 交互层</strong>的陷阱：</p>

<p><code>create_story</code> 创建目录时会把标题中的空格转为连字符（如 <code>&quot;AI 创作的故事&quot;</code> → <code>02-AI-创作的故事</code>），但 LLM 可能回传原始空格形式（<code>&quot;02-AI 创作的故事&quot;</code>）——<code>safeFolder</code> 需要同时匹配两种变体才能命中。</p>

<p><strong>协议层的坑、交互层的坑，我们当天全踩了一遍。</strong></p>

<hr>

<h2 id="总结-三条铁律-一条元教训">总结：三条铁律 + 一条元教训</h2>

<p>如果你只带走三句话，再加上一条「关于修复 Bug 本身的教训」：</p>

<ol>
<li><strong><code>process.exit()</code> 只属于一次性 CLI 命令</strong>。长期运行的进程（MCP Server / watch 模式 / 守护进程）必须由输入流/信号回调控制退出。豁免时要提炼「长期运行」这个抽象，不要枚举具体命令。</li>
<li><strong>stdout 是协议通道，不是日志通道</strong>。任何 stdio 协议服务器中，非协议输出都是污染。诊断信息请走 stderr。</li>
<li><strong><code>close</code> ≠ 所有操作完成</strong>。用 <code>pending</code> Set + <code>Promise.allSettled</code> 显式等待异步操作。</li>
<li><strong>修复 Bug 要提炼「抽象」，而非枚举「实例」</strong>。当代码中出现 <code>if (cmd !== &quot;A&quot; &amp;&amp; cmd !== &quot;B&quot;)</code> 这种排除列表时，说明你在枚举具体命令——新的长期运行命令出现时，同样的 Bug 会在新的地方复发。</li>
</ol>

<p>这四个问题的共同点是：它们<strong>无法通过单元测试发现</strong>，只能在真实进程环境中暴露。所以——写完 handler 后，别忘了写一个 <code>spawnSync</code> 端到端测试。</p>

<p>请注意，这四条铁律是<strong>语言无关</strong>的——无论你是用 Node.js、Python 还是 Go 开发 stdio 服务器，<code>process.exit()</code> / stdout 污染 / 异步未等待 / 枚举而非抽象 这四类坑都存在。本文以 Node.js 为例，只是因为我们的项目恰好是 Node 栈。</p>

<hr>

<p>本文基于 story-cli 项目的真实调试经历撰写。项目地址：<a href="https://github.com/yuelinghuashu/story-cli" target="_blank">story-cli</a></p>
]]></content:encoded>
      <description><![CDATA[深入剖析 MCP stdio 协议的三个隐秘陷阱，记录一次真实的调试经历——从 401 个测试全绿到线上完全无响应，最终排查出 process.exit、stdout 污染和异步竞态三个致命 Bug，并给出端到端测试的解决方案。]]></description>
      <category><![CDATA[TypeScript]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[当 GB18030 解码 UTF-8 时，你看到的可能不是乱码]]></title>
      <link>https://moongate.top/docs/article-encoding-collision</link>
      <guid isPermaLink="true">https://moongate.top/docs/article-encoding-collision</guid>
      <pubDate>Sat, 15 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>前段时间给 <a href="https://github.com/yuelinghuashu/story-cli" target="_blank">story-cli</a>（一个 Git 原生的 Markdown 故事管理 CLI）添加文件编码检测功能时，写了一个测试用例，结果发现了一个非常反直觉的现象。</p>

<h2 id="一次失败的测试">一次失败的测试</h2>

<p>story-cli 需要处理 Windows 用户用记事本保存的 GBK/GB2312 编码文件，因此我实现了一个零依赖的编码检测方案：</p>

<pre><code class="language-ts">// 第一步：严格检测是否合法 UTF-8
function isUtf8(buffer: Uint8Array): boolean {
  try {
    new TextDecoder(&quot;utf-8&quot;, { fatal: true }).decode(buffer)
    return true
  } catch {
    return false
  }
}

// 第二步：非 UTF-8 时，尝试用 GB18030 反检测
function isLikelyGb18030(buffer: Uint8Array): boolean {
  const decoded = new TextDecoder(&quot;gb18030&quot;).decode(buffer)
  const chineseCount = (decoded.match(/[\u4e00-\u9fa5]/g) || []).length
  return chineseCount &gt; 0
}
</code></pre>

<blockquote>
<p>📌 大文件可采样前 2KB 判断编码特征，无需扫描全量文本。</p>
</blockquote>

<p>整个方案的关键在于：<strong><code>isUtf8</code> 先做确定性检查（合法就是合法，不合法就是不合法），<code>isLikelyGb18030</code> 只对「已确认非法 UTF-8」的文件运行</strong>。</p>

<p>我写了一个测试来验证这个方案的可靠性：</p>

<pre><code class="language-ts">test(&quot;isLikelyGb18030 拒绝合法 UTF-8 中文&quot;, () =&gt; {
  const buf = Buffer.from(&quot;# 第一章\n\n这是正文。&quot;, &quot;utf-8&quot;)
  // 期望：UTF-8 中文被 GB18030 解码后是乱码，不含汉字
  assert.strictEqual(isLikelyGb18030(buf), false)
})
</code></pre>

<p>结果，<strong>测试失败了</strong>。实际执行：</p>

<pre><code class="language-text">输入（UTF-8 编码）:  &quot;# 第一章\n\n这是正文。&quot;

用 GB18030 解码后:   &quot;# 杩欐槸姝ｆ枃銆�&quot;
                      ^^^^^^^^^^^^^^^^
                      9 个汉字！

isLikelyGb18030 返回: true
</code></pre>

<p>（注：末尾的 � 是因为「这是正文。」共 15 个字节、是奇数，GB18030 解码器处理末字节落单时产生了替换字符——恰好侧面印证了编码边界的复杂性。）</p>

<p>我的直觉「UTF-8 中文被 GB18030 解码后应该是乱码」——<strong>完全错误</strong>。</p>

<p>值得反思的是，这里「失败的」其实是<strong>测试预期，而非代码逻辑</strong>。<code>isLikelyGb18030</code> 的职责是「判断缓冲区中是否包含汉字」——对于 UTF-8 编码的正常汉字，它返回 <code>true</code> 是<strong>忠于规格的正确行为</strong>。真正的问题在于：这个函数无法区分「真的汉字」和「GB18030 视角下的虚假汉字」，而这一点恰好只有站在 <code>isUtf8</code> 门控外侧才能看清。测试驱动发现的本质，有时不是「代码有 Bug」，而是「你还没想清楚这个函数到底该验证什么」。</p>

<h2 id="为什么-编码空间的字节碰撞">为什么？——编码空间的字节碰撞</h2>

<p>这不是巧合，而是<strong>多字节编码之间的字节空间重叠</strong>导致的必然结果。</p>

<h3 id="utf-8-中文字符的字节结构">UTF-8 中文字符的字节结构</h3>

<pre><code class="language-text">&quot;第&quot;  = UTF-8 三字节序列:  E7 AC AC
       ↑ 首字节范围 0xE0-0xEF
       ↑ 后续字节范围 0x80-0xBF
</code></pre>

<h3 id="gb18030-的字节结构">GB18030 的字节结构</h3>

<pre><code class="language-text">GB18030 合法序列至少包含双字节：
  首字节 0x81-0xFE + 尾字节 0x40-0xFE
（实际上 GB18030 是变长编码，还有 1 字节和 4 字节序列——
  但双字节的字节范围已经足以解释我们的碰撞场景。）
</code></pre>

<h3 id="碰撞发生">碰撞发生</h3>

<p>UTF-8 中文字符的起始字节 <code>0xE0-0xEF</code> 恰好落在 GB18030 首字节范围（<code>0x81-0xFE</code>）内，后续字节也在其合法尾字节范围内。当这些字节被 GB18030 重新解释时，<strong>映射到了 GB18030 字库中真实存在的汉字</strong>：</p>

<pre><code class="language-text">UTF-8 字节:      E7 AC AC
                 ↓ 被 GB18030 重新解释
GB18030 解码:   &quot;杩&quot; ← 一个真实的汉字
</code></pre>

<p>当然，GB18030 的双字节映射码位并非连续——<code>0x81-0xFE</code> 首字节和 <code>0x40-0xFE</code> 尾字节的组合中存在间隔。只是「第」的 UTF-8 字节恰好落入了<strong>有映射的区间</strong>，这才是碰撞发生的充要条件。</p>

<p>这不是概率上的巧合——GB18030 覆盖了 2 万多个汉字，UTF-8 的多字节组合空间同样巨大，两个空间的重叠区域<strong>必然产生大量「虚假但合法」的汉字</strong>。</p>

<h2 id="这个问题的另一面-为什么-ufffd-检测不够用">这个问题的另一面：为什么 <code>\uFFFD</code> 检测不够用</h2>

<p>一开始有人建议用 <code>\uFFFD</code>（替换字符）来检测非 UTF-8 文件——UTF-8 解码失败时会产生这个字符。但这个方案同样存在盲区：</p>

<p><strong>GBK 编码的中文字节有可能恰好构成合法的 UTF-8 序列，解码后输出错误的字符但不产生 <code>\uFFFD</code>。</strong></p>

<pre><code class="language-text">GBK 编码的&quot;中文&quot;字节:  D6 D0 CE C4
                      ↓ 被 UTF-8 重新解释（恰好合法）
UTF-8 解码结果:       &quot;Ր΄&quot; ← 亚美尼亚字母，不产生 U+FFFD
</code></pre>

<p><strong>结论：无论哪个方向，编码检测都不可能做到 100% 准确。</strong></p>

<p>值得一提的是，<code>\uFFFD</code> 并非只是「检测盲区」——它本身就是编码碰撞的参与者。我做了个小实验：</p>

<pre><code class="language-text">BOM 字节（EF BB BF）          → GB18030 解码 → &quot;锘&quot;    ← 意外的汉字
U+FFFD 的 UTF-8 编码（EF BF BD） → GB18030 解码 → &quot;锟&quot;   ← 意外的汉字
</code></pre>

<p>这恰好揭开了「锟斤拷」这个经典乱码的谜底：<strong>GBK 文件被误当 UTF-8 读取时，无法解码的字节变成 U+FFFD；U+FFFD 的 UTF-8 编码（<code>EF BF BD</code>）再被 GBK/GB18030 读取时，就变成了「锟」。</strong> 这不是「更乱的乱码」——而是「乱码的乱码」，每一层转换都在字节空间里找到了新的合法映射。单个 U+FFFD 产生「锟」；当连续两个时（<code>EF BF BD EF BF BD</code>），GB18030 会把它解码为「锟斤拷」——这就是老开发者熟悉的那个经典乱码的完整字节链。</p>

<table>
<thead>
<tr>
<th>方向</th>
<th>可能的结果</th>
<th align="center"><code>\uFFFD</code> 检测</th>
<th align="center">GB18030 反向检测</th>
</tr>
</thead>

<tbody>
<tr>
<td>GBK 字节 → UTF-8 解码</td>
<td>乱码但不产生 <code>\uFFFD</code></td>
<td align="center">❌ 漏检</td>
<td align="center">✅ 可识别</td>
</tr>

<tr>
<td>UTF-8 字节 → GB18030 解码</td>
<td>产生「虚假汉字」</td>
<td align="center">❌ 不适用</td>
<td align="center">⚠️ 可能误报</td>
</tr>

<tr>
<td>GBK 字节 → GB18030 解码</td>
<td>正常汉字</td>
<td align="center">✅ 可识别</td>
<td align="center">✅ 可识别</td>
</tr>

<tr>
<td>纯 ASCII</td>
<td>无乱码</td>
<td align="center">✅ 无风险</td>
<td align="center">✅ 无风险</td>
</tr>
</tbody>
</table>

<blockquote>
<p>需要注意的是，上表展示的是字节流在<strong>任意解码器下</strong>的原始方向性风险。而在我们实际的代码中，通过<strong>确定性门控（先跑 <code>isUtf8</code>）</strong>，已经将「UTF-8 → GB18030」这一方向的误报排除在检测流水线之外——<strong>只有当文件已确认不是合法 UTF-8 时，才会走到第二道门</strong>。因此第二道门可以放心使用启发式方法。</p>
</blockquote>

<h2 id="正确的设计-确定性门控-启发式确认">正确的设计：确定性门控 + 启发式确认</h2>

<p>最终方案不是「用一个检测搞定一切」，而是<strong>分层</strong>：</p>

<pre><code class="language-ts">export function detectEncodingIssue(
  filePath: string,
  buffer: Uint8Array,
): EncodingIssue | null {
  // 采样前 2KB 判断编码特征（大文件性能优化）
  const sample = buffer.length &gt; 2048 ? buffer.subarray(0, 2048) : buffer

  // 第一道门：确定性检测，100% 准确
  if (isUtf8(sample)) return null

  // 第二道门：启发式检测，只在确定区域工作
  const encoding = isLikelyGb18030(sample) ? &quot;GBK/GB18030&quot; : &quot;unknown&quot;
  return { filePath, encoding }
}
</code></pre>

<p>关键设计决策：</p>

<ol>
<li><strong>第一道门是确定性的</strong>——<code>TextDecoder(fatal: true)</code> 要么成功要么抛错，没有模糊地带</li>
<li><strong>第二道门只在「已确认非法 UTF-8」时运行</strong>——此时 GB18030 解码产生汉字是强信号，因为合法 UTF-8 中文已被排除</li>
<li><strong>警告不阻断构建</strong>——即使极端情况下误判为 GBK，用户看到的也只是「可能」的提示，不会阻塞工作流</li>
</ol>

<h2 id="可推广的启示">可推广的启示</h2>

<ol>
<li><strong>「检测」不等于「识别」</strong>——编码检测永远无法完美。好的设计是让确定性检查做门控，让启发式检查在确定区域内提供附加信息</li>
<li><strong>测试驱动发现的真实价值</strong>——如果我没写那条「期望失败」的断言，永远不会知道 GB18030 反解码 UTF-8 会得到汉字。这个发现直接影响了测试策略（改为记录已知限制而非断言）</li>
<li><strong>多字节编码的碰撞无处不在</strong>——同样的现象在 Shift-JIS、Big5、EUC-KR 之间也存在。任何涉及多语言编码的国际化项目都值得注意</li>
</ol>

<blockquote>
<p><strong>一句话总结：</strong> 编码检测没有银弹。用 <code>fatal: true</code> 做硬门控，用启发式方法在门控之后做软提示，别试图让一个函数解决所有问题。</p>
</blockquote>

<hr>

<h2 id="附录-复现实验">附录：复现实验</h2>

<p>你可以亲手跑两个方向的「反直觉」案例：</p>

<pre><code class="language-bash"># 方向一：UTF-8 &quot;第&quot; → GB18030 解码 → &quot;杩&quot;（一个真实汉字）
node -e &quot;console.log(new TextDecoder('gb18030').decode(Buffer.from('第', 'utf-8')))&quot;

# 方向二：GBK &quot;中文&quot; → UTF-8 解码 → 亚美尼亚字母（无 U+FFFD）
node -e &quot;console.log(new TextDecoder('utf-8').decode(Buffer.from('中文', 'gbk')))&quot;
</code></pre>

<p>输出分别是 <code>杩</code> 和 <code>Ր΄</code>——两行代码，就能看到编码碰撞的双向迷局。</p>
]]></content:encoded>
      <description><![CDATA[深入剖析 UTF-8 与 GB18030 编码空间的字节碰撞，揭示编码检测中“合法乱码”的迷局，并提出确定性门控 + 启发式确认的工程化方案。]]></description>
      <category><![CDATA[TypeScript]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Encoding]]></category>
      
    </item>

    <item>
      <title><![CDATA[Flutter 桌面端：输入框设计的细节与边界]]></title>
      <link>https://moongate.top/docs/flutter-desktop-input-design</link>
      <guid isPermaLink="true">https://moongate.top/docs/flutter-desktop-input-design</guid>
      <pubDate>Thu, 13 Aug 2026 23:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="引子-一个用户报来的-bug">引子：一个用户报来的 bug</h2>

<p>&ldquo;在输入框里打字后，第一次回车会换行，第二次才正常提交。&rdquo;</p>

<p>这是 Flutter 桌面端开发中极具代表性的问题：<strong>移动端的输入逻辑无法直接平移到桌面端</strong>。移动端软键盘上的「发送」按钮天然触发 <code>onSubmitted</code>；而桌面端有实体键盘，回车、Shift、方向键都是独立可见的物理事件，它们的语义需要开发者自己定义。</p>

<p>（背景说明：这个输入框来自一个 AI 驱动的交互式叙事应用，用户以「命运」身份输入指令，AI 据此展开故事。输入框和流式回复是这个应用最核心的两个交互入口，所以它的细节值得认真打磨。）</p>

<p>我最初的做法很「直觉」：用一个外层 <code>Focus</code> 包裹 TextField，在里面拦截 Enter 键。结果就有了文章开头那个 bug——第一次回车成了换行。</p>

<h2 id="一-键盘事件真正的传播路径">一、键盘事件真正的传播路径</h2>

<h3 id="直觉为什么是错的">直觉为什么是错的</h3>

<p>大多数人（包括我）会这样写：</p>

<pre><code class="language-dart">Expanded(
  child: Focus(
    onKeyEvent: _handleKeyEvent, // 外层 Focus 拦截
    child: TextField(
      focusNode: _focusNode,
      maxLines: null, // 桌面端多行
      textInputAction: TextInputAction.newline,
    ),
  ),
)
</code></pre>

<p>看起来 <code>onKeyEvent</code> 应该能收到所有按键。但实际上，<strong>键盘事件先到达真正获得焦点的节点</strong>——也就是 TextField 内部的 <code>EditableText</code>——而不是你外面套的那层 <code>Focus</code>。</p>

<p>在 <code>maxLines: null</code> + <code>textInputAction: newline</code> 的组合下，<code>EditableText</code> 收到 Enter 后会：</p>

<ol>
<li>在内部插入一个换行符</li>
<li>返回 <code>KeyEventResult.handled</code>（标记事件已消费）</li>
</ol>

<p>事件一旦被 <code>handled</code>，就<strong>不会继续冒泡</strong>到外层 <code>Focus</code>。你的 <code>_handleKeyEvent</code> 根本收不到这个事件，自然无法拦截。第一次回车变成了换行，第二次才「碰巧」正常提交。</p>

<p>这里「冒泡」这个词，前端读者自然会联想到 <strong>JS 的 DOM 事件冒泡</strong>。两者确实有一个共同点：事件从某一点开始，沿一条链向上传播，中途可以被消费从而停止。但「传播路径」和「中途停止」的细节<strong>完全不同</strong>：</p>

<table>
<thead>
<tr>
<th></th>
<th>JS DOM 事件</th>
<th>Flutter 键盘事件</th>
</tr>
</thead>

<tbody>
<tr>
<td>传播路径由谁决定</td>
<td><strong>DOM 树</strong></td>
<td><strong>焦点链（Focus Chain）</strong></td>
</tr>

<tr>
<td>视觉包裹 = 传播路径？</td>
<td>是</td>
<td>否（焦点关系 ≠ 包裹关系）</td>
</tr>

<tr>
<td>传播方向</td>
<td>capture 下行 → target → 冒泡上行</td>
<td>焦点节点 → 沿焦点链逐级向上</td>
</tr>

<tr>
<td>中途阻止</td>
<td><code>stopPropagation()</code></td>
<td>返回 <code>KeyEventResult.handled</code></td>
</tr>

<tr>
<td>关键区别</td>
<td>任意 DOM 祖先都能收到事件</td>
<td>内层节点可<strong>提前消费</strong>，事件在冒泡到达祖先前就被截断</td>
</tr>
</tbody>
</table>
<p>在 JS 里，外层 <code>div</code> 包裹内层 <code>input</code>，事件<strong>一定</strong>会经过外层 <code>div</code>——视觉包裹关系就是传播路径，你在外层拦截理所当然。但在 Flutter 里，<strong>事件走的是焦点链，而不是 widget 树的包裹关系</strong>：TextField 内部的 <code>EditableText</code> 是当前焦点节点，事件从它开始沿焦点链向上传播。外层 <code>Focus</code> 作为 <code>EditableText</code> 的祖先，<strong>节点确实在焦点链上</strong>——但问题是 <code>EditableText</code> 处理 Enter 时返回了 <code>KeyEventResult.handled</code>，<strong>事件冒泡在到达外层 <code>Focus</code> 之前就被截断了</strong>，根本轮不到外层处理。这才是「外层 Focus 包裹 TextField 却拦截不到回车」的真正原因：不是节点不在链上，而是事件在到达它之前就已被消费。</p>

<h3 id="正确的挂载点">正确的挂载点</h3>

<p>把键盘事件处理<strong>直接绑定在 TextField 自己的 <code>FocusNode</code> 上</strong>：</p>

<pre><code class="language-dart">late FocusNode _focusNode;

@override
void initState() {
  super.initState();
  _focusNode = FocusNode(onKeyEvent: _handleKeyEvent);
}

// build 中不再需要外层 Focus 包裹
Expanded(
  child: TextField(
    focusNode: _focusNode,
    // ...
  ),
)
</code></pre>

<p>这样 <code>_handleKeyEvent</code> 会在 <code>EditableText</code> 处理之前先运行。Enter（无 Shift）返回 <code>handled</code> 阻止换行并发送；Shift+Enter 返回 <code>ignored</code> 放行给 TextField 插入换行。</p>

<p><strong>教训</strong>：在 Flutter 里，「包裹 widget」不等于「能拦截深层的键盘事件」。要拦截什么，就得把监听器挂在事件真正经过的那个节点上。</p>

<h2 id="二-同一个-回车-两个键码">二、同一个「回车」，两个键码</h2>

<p>修复了第一次回车换行后，用户又报来一个新问题：「<strong>方向键区域的回车键还是会导致换行</strong>」。</p>

<p>同一个回车键，为什么字母区正常、方向键区不正常？</p>

<p>因为在 Flutter 里，这两个「回车」是<strong>不同的键码</strong>：</p>

<table>
<thead>
<tr>
<th>键</th>
<th><code>LogicalKeyboardKey</code></th>
</tr>
</thead>

<tbody>
<tr>
<td>主键盘区 Enter</td>
<td><code>enter</code></td>
</tr>

<tr>
<td>方向键区上方 / 数字小键盘 Enter</td>
<td><code>numpadEnter</code></td>
</tr>
</tbody>
</table>
<p>而我的判断写的是：</p>

<pre><code class="language-dart">if (event.logicalKey == LogicalKeyboardKey.enter) {
</code></pre>

<p><code>numpadEnter</code> 不匹配，<code>_handleKeyEvent</code> 对它返回 <code>ignored</code>，事件放行到 TextField，照常插入换行。</p>

<p>修复只需把两个键码都匹配：</p>

<pre><code class="language-dart">if (event.logicalKey == LogicalKeyboardKey.enter ||
    event.logicalKey == LogicalKeyboardKey.numpadEnter) {
</code></pre>

<p><strong>教训</strong>：桌面端键盘不是「一个键 = 一个语义」。同一个物理动作（按回车）在不同区域可能对应不同键码——尤其当你匹配键位时，要想到主键盘区之外的存在。</p>

<h2 id="三-shift-enter-的语义不能丢">三、Shift+Enter 的语义不能丢</h2>

<p>桌面端有一个通行惯例：<strong>Enter 发送、Shift+Enter 换行</strong>。这在聊天工具、终端、编辑器里几乎一致。</p>

<p>实现时要注意的是：Shift+Enter 应该<strong>放行</strong>给 <code>EditableText</code> 处理，而不是自己构造换行：</p>

<pre><code class="language-dart">if (HardwareKeyboard.instance.isShiftPressed) {
  // Shift+Enter → 交给 TextField 插入换行
  return KeyEventResult.ignored;
}
</code></pre>

<p>为什么「放行」比「自己插入换行」可靠？</p>

<ul>
<li>让 EditableText 处理换行，能正确维护光标位置、选择区、IME 组合状态</li>
<li>自己往 controller 里塞 <code>\n</code>，在输入法（中文拼音等）组合期间容易破坏光标上下文</li>
</ul>

<p>判断 Shift 状态用的是 <code>HardwareKeyboard.instance.isShiftPressed</code>——这是 Flutter 当前提供的全局硬件键盘状态查询。需要说明的是：<code>KeyDownEvent</code> 本身<strong>并不携带修饰键状态</strong>（<code>KeyEvent</code> 只有 <code>physicalKey</code> / <code>logicalKey</code> / <code>character</code> / <code>timeStamp</code> 等字段，没有 <code>modifiers</code>），所以判断 Shift 必须依赖 <code>HardwareKeyboard</code> 这个全局单例。</p>

<p>全局状态有一个应注意的边界：它反映的是「<strong>此刻</strong>」的硬件状态，而非「该事件发生的那一刻」。在快速连续按键、弹窗切换焦点后突然松开修饰键等场景，理论上可能读到滞后状态。Flutter 未来的 <code>KeyEvent</code> API 演进方向是让事件携带 <code>modifiers</code> 快照（类似 Web 的 <code>KeyboardEvent</code>），届时事件级判断会比全局状态更可靠——但在当前 Flutter 版本，<code>HardwareKeyboard.instance.isShiftPressed</code> 就是可用的标准做法。</p>

<p>值得一提的是，这里<strong>不需要也不建议</strong>自建「修饰键状态缓存」（自行在 KeyDown 置 true、KeyUp 置 false）——因为 <code>HardwareKeyboard</code> 本身就是 Flutter 框架层维护的全局状态：它内部通过 KeyDown / KeyUp 事件流 + 合成事件（synthesized event）同步机制，保证其状态与事件序列严格一致。举例：焦点切换导致 Shift 松开事件丢失时，Flutter 会注入合成事件来修正状态。自建缓存反而更容易在焦点切换、合成事件等边界场景出错——这正是框架替你处理掉的那部分复杂度。</p>

<h2 id="四-输入历史-widget-生命周期-数据生命周期">四、输入历史：Widget 生命周期 ≠ 数据生命周期</h2>

<h3 id="问题-退出重进后-失效">问题：退出重进后 ↑ / ↓ 失效</h3>

<p>桌面端加了「↑ / ↓ 回溯最近 5 条输入」的快捷键后，第一轮测试一切正常——发送几条消息，按 ↑ 能逐条找回。但用户又说：「退出重新进入之后，↑ 键就没用了。」</p>

<p>原因很简单：</p>

<pre><code class="language-dart">class _InputBarState extends State&lt;InputBar&gt; {
  final List&lt;String&gt; _history = []; // ← 纯内存，Widget 销毁即清空
}
</code></pre>

<p>输入历史被放在了 <code>State</code> 里。当前游玩时 <code>InputBar</code> 一直活着，历史正常累积；一旦退出叙事页、<code>InputBar</code> 被销毁重建，<code>_history</code> 被重置为空。</p>

<p><strong>Widget 生命周期 ≠ 数据生命周期</strong>。<code>State</code> 是为「界面状态」而生的（滚动位置、当前输入框内容），而不是为「用户数据」而生的（跨会话要保留的输入历史）。把持久化数据放进 <code>State</code> 是反模式。</p>

<h3 id="正确方案-state-提升-持久化">正确方案：State 提升 + 持久化</h3>

<p>借鉴 Riverpod 的 <code>Notifier</code> 模式，把输入历史提升为一个全局 Provider，用 <code>SharedPreferences</code> 持久化：</p>

<pre><code class="language-dart">class InputHistoryNotifier extends Notifier&lt;List&lt;String&gt;&gt; {
  static const int maxHistory = 5;
  static const String key = 'mephisto_input_history';

  @override
  List&lt;String&gt; build() =&gt; const [];

  Future&lt;void&gt; push(String text) async {
    if (state.isNotEmpty &amp;&amp; state.last == text) return; // 相邻去重
    final next = [...state, text];
    if (next.length &gt; maxHistory) next.removeAt(0);
    state = next;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(key, jsonEncode(next));
  }
}

// 一个可选的初始化器：从持久化恢复
</code></pre>

<p><code>InputBar</code> 从 <code>State</code> 改为 <code>ConsumerState</code> 后：</p>

<pre><code class="language-dart">List&lt;String&gt; get _history =&gt; ref.watch(inputHistoryProvider);
</code></pre>

<p>发送时写入 Provider，重建后从 Provider 读——历史跨会话保留。</p>

<h3 id="一个设计决策-全局共享-还是按契约隔离">一个设计决策：全局共享，还是按契约隔离？</h3>

<p>用户有过一个很合理的顾虑：&rdquo;如果我有多个正在进行的子版，历史都会保存吗？性能有影响吗？&rdquo;</p>

<p>我最终选了<strong>全局单列表</strong>：</p>

<ul>
<li>存储恒定：单个 <code>SharedPreferences</code> key，最多 5 条短文本（约几 KB），<strong>不随子版文件数量增长</strong></li>
<li>语义合理：不同剧本/分支经常使用类似的方向词（「调查」「询问」「前往」），全局共享反而更方便</li>
<li>实现简单：无需 <code>Map&lt;文件名, List&lt;String&gt;&gt;</code> 序列化</li>
</ul>

<p>按子版隔离（<code>Map</code> 结构）性能上其实也毫无压力（每个子版也就几 KB），但实现复杂、收益有限。对个人项目而言，全局单列表是「够用且简单」的正确取舍。</p>

<p>一个前瞻风险：全局单列表的写入是<strong>异步 <code>setString</code></strong>，如果未来支持<strong>多窗口 / 多 Tab 同时编辑</strong>，存在理论上并发写覆盖的可能（两个窗口各自 push 后互相覆盖）。当前方案适用于单窗口串行场景；若将来引入多窗口，需为写入加防抖合并，或改用文件锁 / 数据库（如 <code>sqlite</code>）保证原子性。</p>

<p>还有一个极端场景的权衡：<code>SharedPreferences.setString</code> 是异步写入，如果使用者在 <code>await</code> 完成前直接关闭应用或系统强杀进程，最后一次写入可能丢失。鉴于输入历史属于<strong>「辅助体验」而非「核心资产」</strong>（丢了只是 ↑ 键少回溯一次，不会损坏叙事数据），这个极低概率的丢失是可接受的——因此未采用双写或事务日志这类过度设计。</p>

<h2 id="五-如何测试这些交互边界">五、如何测试这些交互边界</h2>

<h3 id="键盘事件-模拟桌面端平台">键盘事件：模拟桌面端平台</h3>

<p><code>testWidgets</code> 默认在 FakeAsync 中运行，可以用 <code>sendKeyEvent</code> 直接模拟按键。关键是<strong>指定平台</strong>——<code>InputBar._isDesktop</code> 依据 <code>Theme.of(context).platform</code> 判定，默认是 Android 而非桌面端：</p>

<pre><code class="language-dart">await tester.pumpWidget(buildInputBar(onSend: sent.add)); // 内部设置 ThemeData(platform: linux)
await tester.enterText(find.byType(TextField), '命运指引');
await tester.sendKeyEvent(LogicalKeyboardKey.enter, platform: 'linux');
await tester.pump();

expect(sent, ['命运指引']); // 第一次回车即提交
</code></pre>

<p>同理可以测 Numpad Enter、↑ / ↓ 回溯。</p>

<h3 id="fakeasync-的局限-真实异步-io-不会自动完成">FakeAsync 的局限：真实异步 IO 不会自动完成</h3>

<p>当测试涉及「持久化 → 重建 → 恢复」时，我踩到了一个坑：<code>testWidgets</code> 的 FakeAsync 中，<strong>SharedPreferences 的读取 Future 不会自动完成</strong>——<code>pumpAndSettle</code> 只驱动 scheduled frames，不驱动纯异步 IO。</p>

<p>我最初的「输入历史持久化」widget 测试怎么都过不了：第一次会话写入历史 → 销毁重建 → 按 ↑ 却得不到之前的历史。尝试了 <code>runAsync</code>、多段 <code>pump</code>，最终在这两者之间总是有一层时序矛盾。</p>

<p><strong>结论：不要把「持久化 round-trip」和「UI 回溯」强行塞进一个 widget 测试</strong>。测试拆解更稳定：</p>

<ul>
<li><strong>Provider 层单测</strong>：验证 <code>push</code> 写入、重建容器后能恢复（round-trip）、去重、上限、JSON 损坏容错</li>
<li><strong>Widget 层测试</strong>：验证 ↑ / ↓ 回溯交互行为（在已 mock 持久化的 Provider 之上）</li>
</ul>

<p>两者各自聚焦，互不干扰，避免了组合测试受 FakeAsync 与真实 IO 时序矛盾影响的脆弱性。</p>

<p>Provider 层单测的骨架长这样——<code>SharedPreferences.setMockInitialValues</code> 一行就搞定了内存 mock：</p>

<pre><code class="language-dart">test('round-trip：push 后重建容器可恢复', () async {
  SharedPreferences.setMockInitialValues({});

  final container1 = ProviderContainer();
  await container1.read(inputHistoryProvider.notifier).push('测试历史');
  container1.dispose();

  // 重建容器（模拟应用重启）→ AutoLoadNotifier 从内存 mock 中恢复
  final container2 = ProviderContainer();
  await container2.read(inputHistoryProvider.notifier).load();
  expect(container2.read(inputHistoryProvider), ['测试历史']);
});
</code></pre>

<p>注意：Provider 层的 <code>load()</code> 是纯异步方法，可以在普通 <code>test()</code> 中直接 <code>await</code>，不必碰 <code>testWidgets</code> 的 FakeAsync——这正是把持久化逻辑从 widget 中剥离出来带来的可测性红利。</p>

<p>更进一步的架构取向：可以把持久化抽象为接口（如 <code>InputHistoryStore</code>），Provider 依赖接口而非直接依赖 <code>SharedPreferences</code>——测试时注入内存实现，<strong>彻底摆脱 FakeAsync 与真实 IO 的时序矛盾</strong>。<code>setMockInitialValues</code> 是 Flutter 自带的轻量 mock，足够覆盖当前场景；接口注入则是在需要更严格隔离时的升级路径。</p>

<h2 id="结语">结语</h2>

<p>桌面端输入框的「边界感」来自对三件事的理解：</p>

<ol>
<li><strong>键盘事件的传播路径</strong>：拦截要挂在真正的焦点节点（<code>FocusNode</code>）上，而非外层包裹 widget——事件在 <code>handled</code> 后不会冒泡</li>
<li><strong>键码的物理身份</strong>：匹配物理键（Enter）要考虑 <code>numpadEnter</code>；修饰键状态（Shift）在事件不携带时，可通过 <code>HardwareKeyboard</code> 全局状态查询（并留意其「此刻而非事件当下」的边界）</li>
<li><strong>数据的生命周期</strong>：什么数据放 State、什么数据提升到 Provider + 持久化，取决于它是否需要跨 Widget 生命周期存活——并用分层测试验证（round-trip 放 Provider 层，UI 交互放 widget 层）</li>
</ol>

<p>这些细节在移动端几乎不会遇到——移动端只有一个软键盘回车，也没有「退出重进后状态要保留」的实体文件概念。但一旦要做桌面端，「功能正确」和「体验正确」之间就出现了这道需要认真思考的边界。</p>

<h2 id="术语表">术语表</h2>

<table>
<thead>
<tr>
<th>术语</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>LogicalKeyboardKey</code></td>
<td>Flutter 对按键的「逻辑键」抽象（键位 + 键盘布局映射后），如 <code>enter</code> / <code>numpadEnter</code></td>
</tr>

<tr>
<td><code>PhysicalKeyboardKey</code></td>
<td>物理键位（USB HID 码），与键盘布局无关</td>
</tr>

<tr>
<td><code>KeyEventResult</code></td>
<td>键盘事件处理结果：<code>handled</code>（已消费，不再向上传播）/ <code>ignored</code>（放行，继续传播）</td>
</tr>

<tr>
<td>焦点链（Focus Chain）</td>
<td>键盘事件沿「焦点节点 → 祖先」传播的路径，与 widget 包裹关系无关</td>
</tr>

<tr>
<td><code>HardwareKeyboard</code></td>
<td>Flutter 维护的全局键盘状态（按键 / 修饰键 / 锁定键）查询入口</td>
</tr>
</tbody>
</table>

<hr>

<p>项目：<a href="https://github.com/yuelinghuashu/mephisto-gui" target="_blank">Mephisto</a></p>
]]></content:encoded>
      <description><![CDATA[从「第一次回车换行，第二次才提交」这个 bug 说起，深入 Flutter 的 Focus 链、键码区分、Shift+Enter 语义与输入历史持久化。]]></description>
      <category><![CDATA[Flutter]]></category>
      <category><![CDATA[Dart]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Design System]]></category>
      <dc:relation><![CDATA[series:flutter-practice]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Flutter 流式 UI：AI 回复的打字机体验是怎么实现的]]></title>
      <link>https://moongate.top/docs/flutter-streaming-typewriter</link>
      <guid isPermaLink="true">https://moongate.top/docs/flutter-streaming-typewriter</guid>
      <pubDate>Thu, 13 Aug 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="引子-一个-跳过打字机-按钮的翻车过程">引子：一个「跳过打字机」按钮的翻车过程</h2>

<p>我在一个 AI 叙事应用（用户通过输入命运指令影响 AI 驱动的交互式故事走向）里做了一个「⏩ 跳过打字机」按钮——用户点击后，应该直接看到完整的 AI 回复，而不是等文字慢慢蹦完。</p>

<p>这个按钮在开发日志里经历了三个阶段：</p>

<ul>
<li><strong>第一版</strong>：点了没反应——回调被调用了，用户却什么都没感受到</li>
<li><strong>第二版</strong>：点了内容被截断——动画确实没了，但回复也残缺了</li>
<li><strong>最终版</strong>：点了一下子显示已有全文，LLM 仍在后台悄悄生成完整内容，生成完毕后一次性补齐</li>
</ul>

<p>这三个版本背后，是「流式 UI」这个领域里最容易踩的三个坑。这篇文章把它拆开讲清楚。</p>

<h2 id="一-从-sse-到屏幕-流式渲染的管道">一、从 SSE 到屏幕：流式渲染的管道</h2>

<h3 id="llm-为什么是-蹦-着出字的">LLM 为什么是「蹦」着出字的</h3>

<p>LLM 的回复通过 HTTP 的 SSE（Server-Sent Events）分 chunk 返回。一个典型 chunk 长这样：</p>

<pre><code class="language-text">data: {&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;梅菲斯特出现在&quot;}}]}

data: {&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;书斋门口。&quot;}}]}

data: [DONE]
</code></pre>

<p>每个 chunk 到达的间隔取决于模型生成速度——快的时候几十毫秒，慢的时候可能上秒级。这段「逐字出现」的效果，就是用户感知到的打字机动画。</p>

<h3 id="为什么不能每-chunk-都更新-ui">为什么不能每 chunk 都更新 UI</h3>

<p>如果每收到一个 chunk 就触发一次状态更新，一个几百字的回复可能触发几十上百次 UI 重建，长文本下明显卡顿。所以要做<strong>节流合并</strong>：</p>

<pre><code class="language-dart">final _buffer = StringBuffer();
Timer? _timer;

void onChunk(String chunk) {
  _buffer.write(chunk);
  // 50ms 窗口内累积，窗口结束统一提交一次
  _timer ??= Timer(const Duration(milliseconds: 50), flush);
}

void flush() {
  _timer?.cancel();
  _timer = null;
  state = state.copyWith(content: _buffer.toString());
}
</code></pre>

<p>50ms 的节流窗口把「几十次通知」降为「几百毫秒一次通知」。这个数字不是随便定的：人眼能感知的流畅动画通常在 60fps 以上（约 16.6ms/帧），但<strong>打字机「蹦字」的间隔通常在 100-300ms</strong>——50ms 的合并窗口远小于打字机的感知粒度，对使用者视觉零影响，却能有效合并高频的 chunk 帧渲染，显著降低 UI 开销。</p>

<h3 id="stringbuffer-避免-o-n²-拼接">StringBuffer：避免 O(n²) 拼接</h3>

<p>每次提交时，如果把完整内容存为 <code>pending</code>，再 <code>state.streamingContent = current + pending</code>，长回复下就是反复全量拼接——<strong>O(n²) 复杂度</strong>。</p>

<p>正确做法是维护一个 <code>StringBuffer</code> 累积器，提交时用 <code>toString()</code> 一次性构建：</p>

<pre><code class="language-dart">final _streamingBuffer = StringBuffer();

String applyAndGet(String pending) {
  _streamingBuffer.write(pending);
  return _streamingBuffer.toString(); // O(总长)，而不是 O(n²)
}
</code></pre>

<h2 id="二-跳过动画-的正确语义-静默累积-中止生成">二、「跳过动画」的正确语义：静默累积 ≠ 中止生成</h2>

<p>这是整个流式体验里最微妙、最容易搞反的一个点。</p>

<h3 id="第一版误区-只跳过-ui-节流">第一版误区：只跳过 UI 节流</h3>

<p>我最初的实现是这样：</p>

<pre><code class="language-dart">void revealStreaming() {
  _revealInstant = true;    // 后续 chunk 跳过 50ms 节流
  _flushStreamBuffer();     // 立即提交已缓冲内容
}
</code></pre>

<p><code>_revealInstant = true</code> 后，后续 chunk 不再经过节流，直接提交到 UI。<strong>但这完全没有解决使用者的问题</strong>——因为打字机效果的真实来源是 <strong>LLM 逐 chunk 返回</strong>，而不是 UI 的 50ms 节流。</p>

<p>LLM 仍然是几秒内慢慢吐完整个回复。使用者看到的效果：点击按钮后，文字依然一个接一个出现。「跳过打字机」变成了「跳过节流」，在感知上约等于什么都没做。</p>

<h3 id="第二版误区-触发取消信号">第二版误区：触发取消信号</h3>

<p>为了让「跳过」真正生效，我加了取消信号：</p>

<pre><code class="language-dart">void revealStreaming() {
  _revealInstant = true;
  _flushStreamBuffer();
  cancelGeneration(); // 让 LLM 停止生成
}
</code></pre>

<p>这次确实「立即」了——因为点击后 LLM 被中止，已到达的内容连同 <code>[DONE]</code> 一起作为最终回复返回，所有「剩余未生成的内容」也就永远丢失了。</p>

<p>用户反馈：「点跳过之后，内容直接截断了，之后的内容不再出现。」</p>

<h4 id="问题">问题</h4>

<p>我把「跳过动画」理解成了「跳过生成」。但用户想要的只是「不想看动画」，而不是「不让 AI 把话说完了」。</p>

<h3 id="正解-静默累积-让-llm-把话说完">正解：静默累积，让 LLM 把话说完</h3>

<p>「跳过动画」的正确语义是：<strong>停止 UI 逐字更新</strong>——打字机光标消失、文字不再一点点蹦——但 <strong>LLM 继续在后台完整生成</strong>，生成完毕后一次性把完整回复写进消息列表。</p>

<pre><code class="language-dart">void _appendStreamChunk(String chunk) {
  if (_revealInstant) {
    // 跳过打字机：静默忽略后续 chunk，不触发 UI 重建
    // 完整内容由生成完毕后的 ReplySucceeded 一次性写入
  } else {
    _streaming.append(chunk, _applyStreamChunk);
  }
}
</code></pre>

<p>点击 ⏩ 后：</p>

<ol>
<li><strong>立即 flush</strong> 当前已到达的内容到消息气泡（使用者立刻看到已有全文）</li>
<li><code>_revealInstant = true</code>，后续 chunk <strong>静默忽略</strong>——UI 不再逐字更新</li>
<li>LLM 继续后台生成完毕，<code>ReplySucceeded</code> 携带<strong>完整 <code>reply</code></strong> 一次性写入消息列表</li>
</ol>

<p>关键区分就一句话：</p>

<blockquote>
<p><strong>停止 UI 逐字更新 ≠ 中止 LLM 生成</strong></p>
</blockquote>

<h3 id="一个需要正视的体验空窗">一个需要正视的体验空窗</h3>

<p>静默累积带来一个极端的体验场景：如果模型极慢且回复超长，使用者点击 ⏩ 时 LLM 可能才生成了前 10 个字，而剩余 2000 字还在排队——从点击跳到 <code>ReplySucceeded</code> 之间可能有长达 10-20 秒的<strong>完全无 UI 反馈</strong>空窗期。此时仅靠「光标消失」是不够的，使用者可能误以为应用卡死了。</p>

<p>产品层面的解法：在跳过模式下显示一个极淡的「后台生成中…」指示条（弱化视觉打扰，但让使用者知道系统仍在工作）。这是静默累积「稳住即时体验」与「保护使用者耐心」的平衡点——技术决策之下，往往还跟着一个体验决策。</p>

<h3 id="一个真实存在的兜底缺陷">一个真实存在的兜底缺陷</h3>

<p>静默累积有一个必须正视的风险：<strong>如果 LLM 生成失败（网络超时、服务端 5xx），<code>ReplySucceeded</code> 永远不会到来</strong>。此时不能永远卡在「加载中」。</p>

<p>我的做法是依赖生成流程的全局兜底：</p>

<pre><code class="language-dart">} catch (e, st) {
  debugPrint('生成回复异常: $e\n$st');
  _dispatch(const GenerationFailed(narrativeErrorGenFailed)); // 复位生成状态
} finally {
  endGeneration(); // 释放防重入标志，允许下一条消息
}
</code></pre>

<p>但这暴露了一个真实的体验缺陷：<code>GenerationFailed</code> 会清空 <code>streamingContent</code>——<strong>使用者点 ⏩ 时已经看到的部分内容会消失</strong>，只剩下一条错误提示。</p>

<p>这个问题其实可以<strong>立即修复</strong>，不必等到下一版。既然 <code>_revealInstant = true</code> 后是「静默忽略」后续 chunk，那么失败时的兜底逻辑应当是：<strong>若已进入跳过模式，失败时先 flush 累积缓冲，将已生成的部分作为一条「不完整回复」归档，再标记失败</strong>：</p>

<blockquote>
<p>说明：<code>_flushStreamBuffer()</code> 会把节流缓冲中已累积的内容提交到界面（即 <code>streamingContent</code>）；<code>_streamingContent</code> 就是当前已显示的流式文本。失败兜底时先把这部分作为「不完整回复」归档，再置错误。</p>
</blockquote>

<pre><code class="language-dart">} catch (e, st) {
  debugPrint('生成回复异常: $e\n$st');
  if (_revealInstant) {
    // 跳过模式下：先把已累积的内容作为「不完整回复」归档，保住使用者看到的部分。
    // _flushStreamBuffer() 提交缓冲 → _streamingContent 为当前已显示的流式文本
    _flushStreamBuffer();
    final partial = _streamingContent;
    _dispatch(ReplySucceeded(
      reply: partial,
      // 状态/记忆等沿用当前值……
    ));
  }
  _dispatch(const GenerationFailed(narrativeErrorGenFailed));
} finally {
  endGeneration();
}
</code></pre>

<p>这只改失败分支、不动主框架，就能做到「跳过动画后即使生成失败，已看到的内容也不丢失」。</p>

<h2 id="三-数据一致性-流式中断不丢内容">三、数据一致性：流式中断不丢内容</h2>

<p>Mephisto 里有两个「打断生成」的入口：</p>

<ul>
<li><strong>⏹ 停止</strong>：使用者明确想中断，保留已生成内容</li>
<li><strong>⏩ 跳过</strong>：使用者不想看动画，但希望 LLM 把话说完</li>
</ul>

<p>它们底层复用同一个「协作式取消」信号——<code>LlmClient</code> 收到取消信号后，在下一条 SSE 数据行处停止读取，并返回<strong>已累积的完整内容</strong>：</p>

<pre><code class="language-dart">// LlmClient 的 SSE 循环内
await for (final line in response.stream...) {
  if (isCancelled()) break; // 协作式：下一条数据行处停止
  // ...解析 delta、写入 fullContent、回调 onChunk
}
return fullContent.toString(); // 返回已累积内容，而非抛异常
</code></pre>

<ul>
<li>⏹ <code>stopGenerating()</code>：触发取消 <code>+</code> flush 已显示内容 → 生成流程以「已累积内容」正常收尾</li>
<li>⏩ <code>revealStreaming()</code>：只置 <code>_revealInstant</code>，<strong>不触发取消</strong> → LLM 继续生成，最终完整提交</li>
</ul>

<p>两者都靠「取消后返回已累积内容」保证<strong>不丢已有数据</strong>；区别在于「是否让 LLM 继续」。</p>

<blockquote>
<p><strong>术语澄清</strong>：这里的「取消信号」是<strong>客户端主动停止读取 SSE 流</strong>（下一条数据行处 break），等于客户端断开接收——<strong>服务端/模型可能仍在后台继续生成、继续消耗算力</strong>。它不是一个「向服务端发送中止指令」的操作；LLM API 通常不支持 client-side cancellation。理解这点很重要：你点了「停止」只是自己不再接收，服务端的成本并不会因此立刻终止。</p>
</blockquote>

<h2 id="四-如何测试流式场景">四、如何测试流式场景</h2>

<p>流式 UI 是最难测的场景之一：时序敏感、涉及真实网络 IO、还夹着节流缓冲。好在 Flutter 测试里可以用 <code>MockClient.streaming</code> + <code>StreamController</code> 精确控制时间点。</p>

<h3 id="可控分块流">可控分块流</h3>

<p>核心思路：用一个 <code>StreamController</code> 手动推送 SSE chunk，在<strong>任意时间点</strong>注入「使用者点击 ⏩」的调用，验证后续行为。</p>

<pre><code class="language-dart">final controller = StreamController&lt;List&lt;int&gt;&gt;();
final streamingClient = MockClient.streaming((request, bodyStream) async {
  return http.StreamedResponse(
    controller.stream,
    200,
    headers: {'content-type': 'text/event-stream; charset=utf-8'},
  );
});
</code></pre>

<h3 id="测试-跳过动画不截断-的关键三步">测试「跳过动画不截断」的关键三步</h3>

<pre><code class="language-dart">// 1. 第一段内容到达
controller.add(utf8.encode(sseChunk('梅菲斯特出现在书斋门口。')));
await Future&lt;void&gt;.delayed(const Duration(milliseconds: 50));

// 2. 使用者点击 ⏩ 跳过打字机
notifier.revealStreaming();
expect(state.streamingContent, contains('梅菲斯特出现在书斋门口'));

// 3. 剩余内容继续到达（reveal 后不应中止生成）
controller.add(utf8.encode(sseChunk('他轻声提议进行一场交易。')));
controller.add(utf8.encode('data: [DONE]\n\n'));
await controller.close();
// waitForGeneration：轮询检查状态.isGenerating 直至复位，
// 确保 LLM 生成的异步流程（含自动存档）完全收尾后再断言
await waitForGeneration(container);

// 最终回复 = 完整内容（不截断）
expect(state.messages.last.content,
    '梅菲斯特出现在书斋门口。他轻声提议进行一场交易。');
</code></pre>

<p>这个测试一举验证了三个关键不变量：</p>

<ol>
<li>点击 ⏩ 后，<strong>已有内容立即显示</strong></li>
<li>reveal 后，后续 chunk <strong>仍然被接收</strong>（未被中止）</li>
<li>生成结束后，消息列表写入的是<strong>完整拼接内容</strong>（不截断）</li>
</ol>

<p>如果当时先写了这个测试，第二版「取消信号导致截断」的 bug 会在测试里立刻暴露——而不是等用户反馈。</p>

<h2 id="结语">结语</h2>

<p>流式 UI 的「顺滑」不是靠运气，而是靠一套清晰的语义决策。总结三条：</p>

<ol>
<li><strong>节流不等于动画</strong>：动画来自 LLM 的逐 chunk 输出，节流只是减少 UI 重建的优化手段</li>
<li><strong>跳过不是中止</strong>：跳过动画的语义是「停止逐字更新」，不是「停止生成」——让 LLM 把话说完，再一次性补齐</li>
<li><strong>中断要可测</strong>：用 <code>StreamController</code> 可控模拟分块流，把「中途打断」这个最脆弱的时序场景变成可重复验证的测试</li>
</ol>

<h2 id="术语表">术语表</h2>

<table>
<thead>
<tr>
<th>术语</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>SSE（Server-Sent Events）</td>
<td>HTTP 长连接的服务器推送协议，LLM 逐 token/chunk 返回的载体</td>
</tr>

<tr>
<td>节流（Throttle）</td>
<td>在时间窗口内合并多次到达为一次提交，减少 UI 重建次数</td>
</tr>

<tr>
<td><code>StringBuffer</code></td>
<td>Dart 的可变字符串累积器，<code>toString()</code> 一次性构建，避免 O(n²) 拼接</td>
</tr>

<tr>
<td><code>StreamController</code></td>
<td>Dart 的可控流控制器，测试中手动推送 chunk 模拟服务器时序</td>
</tr>

<tr>
<td>协作式取消（Collaborative Cancellation）</td>
<td>客户端停止读取 SSE 流的机制：下一条数据行处 break，而非中断底层连接</td>
</tr>

<tr>
<td><code>MockClient.streaming</code></td>
<td><code>package:http/testing</code> 提供的流式响应 mock，可配 <code>StreamedResponse</code></td>
</tr>
</tbody>
</table>

<hr>

<p>项目：<a href="https://github.com/yuelinghuashu/mephisto-gui" target="_blank">Mephisto</a></p>
]]></content:encoded>
      <description><![CDATA[为什么「跳过动画」不等于「中止生成」？从 SSE 到屏幕，拆解流式 UI 的节流、取消语义与可测试性设计。]]></description>
      <category><![CDATA[Flutter]]></category>
      <category><![CDATA[Dart]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Performance]]></category>
      <category><![CDATA[State Management]]></category>
      <dc:relation><![CDATA[series:flutter-practice]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[拆超大 Flutter State 类的三种尝试与最终方案]]></title>
      <link>https://moongate.top/docs/refactoring-flutter-state-class</link>
      <guid isPermaLink="true">https://moongate.top/docs/refactoring-flutter-state-class</guid>
      <pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="1-问题信号-什么时候该拆">1. 问题信号：什么时候该拆</h2>

<p>当一个 <strong>State 类</strong>长到 <strong>几百行</strong>，通常意味着它承担了多种职责：</p>

<p>以真实项目里的首页为例，800+ 行的 <code>_HomeScreenState</code> 里混着：</p>

<table>
<thead>
<tr>
<th>职责</th>
<th>示例方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>契约树操作</td>
<td>母版/子版 ⋮ 菜单、重命名、删除确认</td>
</tr>

<tr>
<td>舞台操作</td>
<td>舞台卡菜单、多选、角色行菜单</td>
</tr>

<tr>
<td>导航</td>
<td>进入叙事页 / 舞台页</td>
</tr>

<tr>
<td>文件操作</td>
<td>导入 / 新建 / 恢复内置</td>
</tr>

<tr>
<td>纯渲染</td>
<td>契约树列表、舞台聚合区、最近编辑计算</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>判断准则</strong>：如果同一个类里有超过 3 种「互不相关的关注点」，就该拆了。
行数不是目标，<strong>职责混杂</strong>才是根因。</p>
</blockquote>

<h2 id="2-尝试一-抽-mixin-败给库级私有">2. 尝试一：抽 Mixin → 败给库级私有</h2>

<p>最常见的直觉是「把方法抽成 Mixin，然后 <code>with</code> 进来」：</p>

<pre><code class="language-dart">// home_screen.dart
class _HomeScreenState extends ConsumerState&lt;HomeScreen&gt;
    with HomeContractActions, HomeStageActions { ... }

// home_contract_actions.dart（另一个文件）
mixin HomeContractActions on ConsumerState&lt;HomeScreen&gt; {
  Future&lt;void&gt; _handleMasterMenu(...) { ... }  // ❌ 宿主访问不到！
}
</code></pre>

<h3 id="报错">报错</h3>

<p><code>The method '_handleMasterMenu' isn't defined for the type '_HomeScreenState'</code></p>

<h3 id="为什么">为什么？</h3>

<p>Dart 的 <code>_</code> 前缀私有是 <strong>library-level（库级）私有</strong>，而不是 class-level（类级）私有。</p>

<ul>
<li>很多语言（Java / C++ / TS）的 <code>private</code> 是「类内可见」——同一个类里混入的成员自然可见；</li>
<li>Dart 的 <code>_</code> 是「<strong>同一个库文件内</strong>可见」——跨文件的 Mixin 与方法定义处于不同库，即使 <code>with</code> 进同一个类，宿主也看不见 Mixin 里的私有方法。</li>
</ul>

<p>有个「看似可行」的桥接：让 Mixin 声明抽象 getter，宿主提供实现：</p>

<pre><code class="language-dart">mixin HomeStageActions on ConsumerState&lt;HomeScreen&gt; {
  HomeSelectionController get selection; // 抽象 getter
  void refreshLists();
  Future&lt;void&gt; _handleStageMenu(...) { ... } // ❌ 仍是私有，宿主看不到
}
</code></pre>

<p>数据桥接解决了（<code>selection</code> / <code>refreshLists</code>），但 <strong>方法可见性</strong>问题没解决——<code>_handleStageMenu</code> 依旧是另一个库的私有方法。</p>

<h3 id="澄清-问题不是-mixin-而是-跨文件">澄清：问题不是「Mixin」，而是「跨文件」</h3>

<p>如果你是<strong>在同一文件内</strong>定义 Mixin 再 <code>with</code>，是完全可行的：</p>

<pre><code class="language-dart">// home_screen.dart（同一个文件）
mixin HomeStageActions on ConsumerState&lt;HomeScreen&gt; {
  Future&lt;void&gt; _handleStageMenu(...) { ... } // ✅ 同文件，私有可见
}

class _HomeScreenState extends ConsumerState&lt;HomeScreen&gt;
    with HomeStageActions { ... }
</code></pre>

<p>所以「Mixin 拆 State」的直觉本身没错。真正无解的约束是：<strong>Dart 的库级私有 + 文件天然构成库边界</strong>。一旦方法搬去另一个文件，<code>_</code> 私有在跨库时就成了硬隔离。</p>

<blockquote>
<p>📌 <strong>核心教训</strong>：Dart 的私有不是「类私」，而是「库私」。
跨文件组织代码时，<code>_</code> 前缀成员默认互相隔离；Mixin 若留在同一文件则不受此限制。</p>
</blockquote>

<h2 id="3-尝试二-用-part-败给无-this">3. 尝试二：用 part → 败给无 this</h2>

<p>既然 <code>_</code> 是库级私有，那用 <code>part</code> 把同一个库拆成多个文件，不就能共享私有成员了吗？</p>

<pre><code class="language-dart">// home_screen.dart
part 'home_screen_contract.dart';
part 'home_screen_stage.dart';

// home_screen_contract.dart
part of 'home_screen.dart';

Future&lt;void&gt; _handleMasterMenu(...) { ... } // ✅ 私有可见了
</code></pre>

<p><code>part of</code> 确实让私有成员可见（我看到了 <code>_selection</code> / <code>_refreshLists</code>），<strong>但是新的问题来了</strong>：</p>

<blockquote>
<p><strong>顶层函数没有 <code>this</code> 上下文。</strong></p>
</blockquote>

<p><code>part of</code> 里的代码是<strong>库级顶层函数</strong>，不是宿主类的方法。于是：</p>

<pre><code class="language-dart">part of 'home_screen.dart';

Future&lt;void&gt; editContract(ContractInfo info) {
  return editContractFile(
    context,          // ❌ Undefined name 'context'
    onRefreshLists: _refreshLists, // ❌ Undefined name '_refreshLists'
  );
}
</code></pre>

<p><code>context</code> / <code>ref</code> / <code>mounted</code> / <code>_selection</code> 都是 <code>_HomeScreenState</code> 的<strong>实例成员</strong>，顶层函数无法访问。</p>

<h3 id="那-part-到底什么时候合理">那 <code>part</code> 到底什么时候合理？</h3>

<p>项目里早就有成功的先例——<strong>freezed 生成代码</strong>：</p>

<pre><code class="language-dart">// narrative_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'narrative_state.freezed.dart'; // ✅ 纯生成代码，无 this 依赖
</code></pre>

<p><code>narrative_state.freezed.dart</code> 是 freezed 工具生成的、只依赖 <code>@freezed</code> 注解产生的 <code>_NarrativeState</code> 类，它<strong>不需要访问宿主的实例成员</strong>。这种「纯声明 + 生成实现」的拆分才是 <code>part</code> 的正解。</p>

<blockquote>
<p>📌 <strong>核心教训</strong>：<code>part</code> 分享的是「库级私有」，而非「宿主实例上下文」。
如果你的拆分目标是一堆要访问 <code>this</code> 的方法，<code>part</code> 不是答案。</p>
</blockquote>

<h2 id="4-最终方案-widget-组合-回归框架哲学-与合理的边界">4. 最终方案：Widget 组合 → 回归框架哲学（与合理的边界）</h2>

<p>绕了两圈，最终回到 <strong>Flutter 组合（Composition）</strong> 的经典答案：</p>

<blockquote>
<p><strong>把「渲染」抽成独立 Widget，把「交互」用回调注入。</strong></p>
</blockquote>

<h3 id="核心原则">核心原则</h3>

<table>
<thead>
<tr>
<th>内容</th>
<th>归属</th>
<th>理由</th>
</tr>
</thead>

<tbody>
<tr>
<td>依赖 <code>ref</code> / <code>context</code> / <code>mounted</code> 的<strong>交互编排</strong></td>
<td>留在 State</td>
<td>这些本就是 State 的生命周期能力</td>
</tr>

<tr>
<td>只读的<strong>视图构建</strong></td>
<td>抽成独立 ConsumerWidget</td>
<td>纯函数式渲染，可独立维护与测试</td>
</tr>
</tbody>
</table>

<h3 id="具体做法">具体做法</h3>

<p>把「契约树 + 舞台聚合 + 最近编辑」这一大段<strong>纯渲染</strong>抽成 <code>ContractTreeSection</code>：</p>

<pre><code class="language-dart">class ContractTreeSection extends ConsumerWidget {
  final List&lt;ContractGroup&gt; groups;
  final HomeSelectionController selection;

  // ---- 所有交互通过回调注入 ----
  final void Function(BuildContext, ContractGroup) onMasterTap;
  final void Function(ContractInfo child, String action) onChildMenu;
  final void Function(String stagePath) onStageTap;
  // ...

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 只剩渲染：ListView + ContractCard + StageSection ...
  }
}
</code></pre>

<p>宿主 <code>_HomeScreenState</code> 只剩「组装 + 委托」：</p>

<pre><code class="language-dart">return ContractTreeSection(
  groups: groups,
  selection: _selection,
  onMasterTap: _onMasterTap,
  onChildMenu: (child, action) =&gt; _handleChildMenu(context, child, action),
  onStageTap: _openStageNarrative,
  // ...
);
</code></pre>

<h4 id="效果-拆分-删代码-而是职责重新归位">效果（拆分 ≠ 删代码，而是职责重新归位）</h4>

<table>
<thead>
<tr>
<th>文件</th>
<th>拆分前行数</th>
<th>拆分后行数</th>
<th>职责</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>home_screen.dart</code></td>
<td>816</td>
<td>640</td>
<td>交互编排（组装 + 导航 + 委托）</td>
</tr>

<tr>
<td><code>contract_tree_section.dart</code></td>
<td>—</td>
<td>263</td>
<td>契约树 + 舞台聚合 + 最近编辑（纯渲染）</td>
</tr>

<tr>
<td><code>stage_card.dart</code></td>
<td>806</td>
<td>529</td>
<td>纯展示 <code>StageCard</code></td>
</tr>

<tr>
<td><code>stage_section.dart</code></td>
<td>—</td>
<td>192</td>
<td>舞台列表区</td>
</tr>

<tr>
<td><code>stage_card_with_meta.dart</code></td>
<td>—</td>
<td>92</td>
<td>舞台数据加载胶水层</td>
</tr>
</tbody>
</table>
<p><strong>总代码量不变</strong>，但每个文件的职责从「混杂」变成「单一」；纯展示组件（<code>StageCard</code> / <code>ContractTreeSection</code>）还能脱离 State 独立测试。</p>

<blockquote>
<p><code>stage_card_with_meta.dart</code> 是「数据加载」与「纯渲染」之间的薄胶水：它负责从 Riverpod 读取舞台数据（角色列表 / 存档探测 / 最近活动时间），再原样传给纯展示的 <code>StageCard</code>——这样 <code>StageCard</code> 自身可以保持零 Riverpod 依赖、便于独立测试。</p>
</blockquote>

<h3 id="为什么这才是-flutter-的方式">为什么这才是「Flutter 的方式」？</h3>

<p>Flutter 的组合模型（Composition）天然就是「一个 Widget 渲染，回调交给上层」：</p>

<ul>
<li><code>Button.onPressed</code>、<code>TextField.onChanged</code>、<code>ListView.builder</code> 全是回调解耦的例子；</li>
<li>项目里 <code>ContractCard</code> / <code>StageSection</code> 早就这么做了——<strong>只是这次把「完整页面的一整块渲染」也套用了同一模式</strong>；</li>
<li><strong>同时缩小重绘范围</strong>：抽成独立 <code>ConsumerWidget</code> 后，State 中其他字段变化不会触达这棵子树——Flutter 的 <code>Element</code> 复用 + 子树隔离让无关更新不再重绘 <code>ContractTreeSection</code>。</li>
</ul>

<p>Mixin 是给<strong>行为复用</strong>用的（如 <code>GenerationCoordinator</code>）；用它拆「State 的组织」，本身就是错配。</p>

<blockquote>
<p>💡 <strong>TIPS：回调里的 <code>String</code> 该不该换成 <code>enum</code>？</strong>
<code>action</code> 用 <code>String</code> 可能触发类型安全直觉。但 PopupMenu 场景下这是务实取舍：<code>PopupMenuItem&lt;String&gt;</code> 天然以 String 为载荷、<code>menu_actions.dart</code> 常量层已提供编译期防护、菜单动作是持续演进的集合（enum 每加一个动作要改定义 + 所有 <code>switch</code>）。
<strong>结论</strong>：若 action 是「封闭且稳定」的集合（如 <code>MessageRole</code>），用 enum 换 exhaustiveness 值得；否则 String + 常量更贴合生态。</p>
</blockquote>

<h3 id="我们为什么停在这里-不继续抽协调器">我们为什么停在这里（不继续抽协调器）</h3>

<p>把交互编排抽到 State 之外（如 Riverpod <code>Notifier</code> 协调器）是常见的进阶建议，但我们评估后<strong>选择不做</strong>：</p>

<ul>
<li>State 里剩下的导航 / 弹窗 / 菜单分发<strong>本质是 UI 职责</strong>——<code>Navigator.push</code>、<code>ScaffoldMessenger</code>、确认弹窗无论如何都需要 <code>BuildContext</code>，抽进 Notifier 只是把壳搬走，参数一个不少；</li>
<li>这些逻辑<strong>早已抽到顶层函数</strong>（<code>home_operations.dart</code> / <code>home_menu_actions.dart</code>），State 里剩的是清晰的一行委托；</li>
<li>再抽协调器会引入「<code>ref.read(coordinator.notifier).xxx()</code> + <code>context.mounted</code>」的额外间接层，认知负担不降反升。</li>
</ul>

<blockquote>
<p>所以 640 行不是「没拆完」，而是<strong>拆到职责单一后的合理停点</strong>。维护性看的是「每个方法一眼可见它干什么」，不是行数。
📌 <strong>核心教训</strong>：遇到「语言机制」限制，不要用 <code>dynamic</code> 或变通去硬绕。
换个更符合框架哲学的架构，通常才是正解；<strong>知道何时停下来，同样是架构判断的一部分</strong>。</p>
</blockquote>

<h2 id="5-决策树-三种方案怎么选">5. 决策树：三种方案怎么选</h2>

<pre><code class="language-text">遇到「State 类太大」
│
├─ 想拆的是「行为复用」？（如流式节流、生成协调）
│    └─ ✅ 用 Mixin（同一文件内定义，避免库私坑）
│
├─ 拆的是「纯数据 / 生成代码」？（如 freezed .g.dart）
│    └─ ✅ 用 part / part of
│
└─ 拆的是「要访问 this 的 State 方法」？
     ├─ 纯渲染部分 → ✅ 抽独立 Widget + 回调注入
     └─ 交互编排部分 → ✅ 留在 State
</code></pre>

<table>
<thead>
<tr>
<th>方案</th>
<th>适用场景</th>
<th>关键限制</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Mixin</strong></td>
<td>行为复用（无私有跨库）</td>
<td><code>_</code> 是库级私有，跨文件不可见</td>
</tr>

<tr>
<td><strong>part</strong></td>
<td>生成代码 / 纯声明</td>
<td>共享私有但无 <code>this</code> 上下文</td>
</tr>

<tr>
<td><strong>独立 Widget + 回调</strong></td>
<td>渲染与交互解耦</td>
<td>需显式传参（回调注入）</td>
</tr>
</tbody>
</table>

<h2 id="6-落地的工程保障">6. 落地的工程保障</h2>

<p>重构最容易翻车，所以<strong>纯移动 + 纯提参 = 零行为变更</strong>是底线：</p>

<ol>
<li><strong>保持行为不变</strong>：只移动方法体、改写调用点，不改任何逻辑；</li>
<li><strong>静态分析兜底</strong>：<code>flutter analyze</code> 必须是 <code>No issues found!</code>；</li>
<li><strong>全量测试兜底</strong>：406 个测试全绿再算完成；</li>
<li><strong>小步提交</strong>：每次拆分一个关注点，独立验证，避免「大爆炸式」重构。</li>
</ol>

<h2 id="总结">总结</h2>

<ul>
<li>Dart 的 <code>_</code> 是<strong>库级私有</strong>，不是类级私有 → Mixin 跨文件失败；</li>
<li><code>part</code> 分享私有但无 <code>this</code> → 不适合拆 State 方法；</li>
<li><strong>抽独立 Widget + 回调注入</strong> 是拆超大 State 类的 Flutter 正解；</li>
<li>渲染与交互解耦，既符合框架哲学，又能独立维护与测试。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[当 800 行 State 类混杂契约树、舞台操作、导航等多种职责时，如何安全拆分？本文记录了从 Mixin、part 到 Widget 组合的完整重构历程，深入剖析 Dart 库级私有特性，并给出可复用的决策树与工程保障策略。]]></description>
      <category><![CDATA[Flutter]]></category>
      <category><![CDATA[Dart]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:flutter-practice]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[品牌生态：设计哲学与视觉契约]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-brand-ecosystem</link>
      <guid isPermaLink="true">https://moongate.top/docs/create-vscode-theme-brand-ecosystem</guid>
      <pubDate>Thu, 06 Aug 2026 08:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="引言-从工程化到设计系统的跃迁">🌕 引言：从工程化到设计系统的跃迁</h2>

<p>在前四篇中，我们一步步构建了 Moongate 主题的工程基础：从手动 JSON 到模块化 YAML，从 DTCG 三层架构到可测试的工业级构建体系。至此，我们拥有了一套高效、可扩展、可自我验证的主题生产系统。</p>

<p>但一个真正优秀的主题，不应只是颜色规则的集合，而应是一套<strong>完整的设计系统</strong>——它包含明确的设计哲学、可复用的视觉语言、与用户沟通的契约，以及可跨平台复用的品牌生态。</p>

<p>本篇将完成最后的跃迁：从「工程化」升维到「设计系统」。你会看到，前四篇建立的工程能力（DTCG 令牌、构建产物、自动化验证）如何支撑起一套可跨平台复用的品牌生态。</p>

<hr>

<h2 id="第一部分-设计哲学-让每个颜色都有意义">🎨 第一部分：设计哲学——让每个颜色都有意义</h2>

<p>任何设计系统都必须建立在清晰的设计哲学之上。没有哲学的颜色只是随机的搭配，有哲学的颜色才能形成品牌识别。</p>

<h3 id="1-1-冷调基底-消除视觉脏感">1.1 冷调基底：消除视觉脏感</h3>

<p><strong>通用原则</strong>：为背景、边框、灰阶添加微弱的冷色偏置（如蓝或绿），避免纯黑或纯白带来的视觉「脏感」。纯黑背景会让高亮色产生「发光溢散」，纯白背景则容易发黄发灰。微量的冷调能让底色干净、深邃，让叠加的彩色语义色更纯粹地呈现。</p>

<p><strong>Moongate 实例</strong>：</p>

<ul>
<li>深色背景：<code>#0f172a</code>（深空蓝黑）</li>
<li>浅色背景：<code>#f9fafb</code>（冷月白）</li>
</ul>

<h3 id="1-2-语义分层-信息的三级阶梯">1.2 语义分层：信息的三级阶梯</h3>

<p><strong>通用原则</strong>：代码不是平面的，天然具有层次。将所有代码元素划分为三个视觉层级——前景（核心逻辑）、中景（普通代码）、背景（辅助信息），通过对比度、饱和度、字体样式（加粗、斜体）等区分，让代码结构自然「浮现」。</p>

<p><strong>Moongate 实例</strong>：</p>

<table>
<thead>
<tr>
<th>层级</th>
<th>作用</th>
<th>视觉特征</th>
<th>示例</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>前景</strong></td>
<td>核心逻辑</td>
<td>高对比度、加粗或高饱和色</td>
<td>关键字、函数定义、类名</td>
</tr>

<tr>
<td><strong>中景</strong></td>
<td>普通代码</td>
<td>中等对比度，视觉自然</td>
<td>变量、字符串、数字</td>
</tr>

<tr>
<td><strong>背景</strong></td>
<td>辅助信息</td>
<td>弱化但可读</td>
<td>注释、标点、操作符</td>
</tr>
</tbody>
</table>
<p>这套分层贯穿所有语言、所有主题变体，是 Moongate 视觉一致性的底层保证。</p>

<h3 id="1-3-重力补偿-昼夜视觉重量对等">1.3 重力补偿：昼夜视觉重量对等</h3>

<p><strong>通用原则</strong>：浅色主题不是深色主题的简单反相。深色背景上的亮色是「发光体」，浅色背景上的暗色是「吸光体」。要让同一语义角色在不同背景下拥有对等的视觉重量，必须保持色相不变，科学调整明度和饱和度。这称为<strong>重力补偿</strong>。</p>

<p><strong>Moongate 实例</strong>：</p>

<table>
<thead>
<tr>
<th>语义角色</th>
<th>深色版</th>
<th>浅色版</th>
<th>调整方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>主色</td>
<td><code>#3b82f6</code> (60% 明度)</td>
<td><code>#0284c7</code> (48% 明度)</td>
<td>色相不变，明度降低约 20%</td>
</tr>

<tr>
<td>成功</td>
<td><code>#34d399</code> (65%)</td>
<td><code>#059669</code> (40%)</td>
<td>明度降低，饱和度略降</td>
</tr>

<tr>
<td>警告</td>
<td><code>#fbbf24</code> (75%)</td>
<td><code>#b45309</code> (35%)</td>
<td>从亮黄转为橙黄，避免在白底上「消失」</td>
</tr>

<tr>
<td>错误</td>
<td><code>#f87171</code> (60%)</td>
<td><code>#b91c1c</code> (35%)</td>
<td>深红保持警示感</td>
</tr>
</tbody>
</table>
<p>用户切换主题时，同一语法元素的视觉重量几乎不变，无需重新适应。</p>

<h3 id="1-4-海拔系统-为-ui-注入物理深度">1.4 海拔系统：为 UI 注入物理深度</h3>

<p><strong>通用原则</strong>：通过定义多级背景明度阶梯，表达 UI 元素的物理深度。深色模式下，海拔越高表面越亮（明度递增）；浅色模式下，海拔越高表面也越亮（但使用更高亮度的白色或浅色）。阶梯步长应保持一致，形成平滑的层次感。</p>

<p><strong>Moongate 实例</strong>：</p>

<table>
<thead>
<tr>
<th>海拔层级</th>
<th>用途</th>
<th>深色模式</th>
<th>浅色模式</th>
<th>明度变化</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>surfaceGround</code></td>
<td>底层背景</td>
<td><code>#0f172a</code></td>
<td><code>#f9fafb</code></td>
<td>基准层</td>
</tr>

<tr>
<td><code>surfaceRaised</code></td>
<td>侧边栏、活动栏</td>
<td><code>#1a2538</code></td>
<td><code>#ffffff</code></td>
<td>深色 +5%，浅色纯白</td>
</tr>

<tr>
<td><code>surfaceFloating</code></td>
<td>面板、悬浮卡片</td>
<td><code>#25364a</code></td>
<td><code>#f1f5f9</code></td>
<td>深色再 +5%，浅色浅灰蓝</td>
</tr>

<tr>
<td><code>surfaceTooltip</code></td>
<td>提示框、弹窗</td>
<td><code>#2e3b4d</code></td>
<td><code>#e2e8f0</code></td>
<td>最高层</td>
</tr>
</tbody>
</table>
<p>这种设计让侧边栏微微隆起，弹窗轻盈浮现，代码区沉静深邃——编辑器从平面走向立体，物理隐喻让界面层次一目了然。</p>

<hr>

<h2 id="第二部分-视觉契约-连接用户与硬件">📄 第二部分：视觉契约——连接用户与硬件</h2>

<p><strong>通用原则</strong>：主题设计得再好，如果用户显示器未校准，效果也会大打折扣。提供一份显示器校准指南（视觉契约），帮助用户调整 Gamma、亮度、对比度、色温，并提醒关闭「动态对比度」「生动模式」等味精功能。校准的终点不是理论完美，而是找到用户最舒服的平衡点。</p>

<p><strong>Moongate 实例</strong>：</p>

<p><code>extras/VISUAL_CONTRACT.md</code> 提供了详细的《视觉契约》文档（v2.0 版），包含以下核心步骤：</p>

<table>
<thead>
<tr>
<th>步骤</th>
<th>操作</th>
<th>目标</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>1. 设置 Gamma</strong></td>
<td>选择 <code>Gamma 2.2</code></td>
<td>确保灰阶过渡平滑</td>
</tr>

<tr>
<td><strong>2. 调整亮度</strong></td>
<td>深色模式：让 2% 灰块刚可见；浅色模式：让 250–255 亮块层次分明</td>
<td>保留暗部/亮部细节</td>
</tr>

<tr>
<td><strong>3. 调整对比度</strong></td>
<td>让 100% 白色块清晰但不刺眼</td>
<td>防止过曝</td>
</tr>

<tr>
<td><strong>4. 色温</strong></td>
<td>推荐 <code>6500K</code> 或 <code>暖色</code> 模式</td>
<td>中和蓝光</td>
</tr>
</tbody>
</table>
<p>v2.0 的视觉契约特别强调<strong>分模式校准</strong>：</p>

<ul>
<li><strong>深色模式</strong>（夜间环境）：在黑暗房间中，调节亮度使 <a href="http://www.lagom.nl/lcd-test/black.php" target="_blank">Black level 测试页</a> 上 2% 灰块刚能被分辨。</li>
<li><strong>浅色模式</strong>（白天环境）：在典型照明下，使用 <a href="http://www.lagom.nl/lcd-test/white_saturation.php" target="_blank">White saturation 测试页</a>，让 250-255 亮块层次分明且 255 纯白不刺眼。</li>
</ul>

<p>并列举了常见显示器陷阱及其对策：</p>

<table>
<thead>
<tr>
<th>陷阱</th>
<th>症状</th>
<th>对策</th>
</tr>
</thead>

<tbody>
<tr>
<td>黑色稳定器</td>
<td>深色背景发灰、暗色文字变淡</td>
<td>锁定为 50 或关闭</td>
</tr>

<tr>
<td>生动模式/高色域</td>
<td>色彩偏离设计、冷色调泛暖</td>
<td>优先选择 sRGB 模式</td>
</tr>

<tr>
<td>锐利度过高</td>
<td>字符边缘出现「重影」</td>
<td>下调至 50-60</td>
</tr>
</tbody>
</table>

<blockquote>
<p>💡 <strong>为什么视觉契约是「契约」而不是「教程」？</strong> 因为它不是指导用户如何校准显示器，而是<strong>双方共同遵守的约定</strong>：主题开发者承诺「颜色经过精密设计」，用户承诺「通过合理校准让设计被忠实呈现」。这份契约让主题在各种硬件上的表现可控、可预期。</p>
</blockquote>

<h3 id="快速版-3-步-够用就好">🚀 快速版：3 步「够用就好」</h3>

<p>完整校准对追求精准的用户很有价值，但如果你不想折腾硬件参数，3 步就能获得够好的体验：</p>

<ol>
<li><strong>选对模式</strong>：显示器切到 <strong>sRGB / 标准模式</strong>，关闭「动态对比度」「生动模式」「黑色稳定器」等味精功能——这一步解决 80% 的颜色偏移问题。</li>
<li><strong>看效果微调</strong>：深色模式下发灰、浅色模式下文字刺眼，就微调亮度直到观感舒服。</li>
<li><strong>色温保底</strong>：选择 <strong>6500K 或暖色</strong>，中和蓝光即可。</li>
</ol>

<p>不追求 Gamma 曲线和测试块的硬件级精确，「观感舒适」就是第一标准。等想要更精细的效果时，再按上文的完整步骤校准。</p>

<hr>

<h2 id="第三部分-品牌生态-从主题到社区">🌐 第三部分：品牌生态——从主题到社区</h2>

<p><strong>通用原则</strong>：完整的品牌体系包括：文档体系（README、CHANGELOG、设计文档）、社区互动（开源、反馈渠道）、生态集成（与周边工具深度整合，提供一致体验）。</p>

<p><strong>Moongate 实例</strong>：### 3.1 文档体系</p>

<ul>
<li><strong>README</strong>：中英双语，包含预览图、设计理念、优化项清单、推荐配置。</li>
<li><strong>CHANGELOG</strong>：按版本记录所有变更，折叠旧版本，突出当前亮点。</li>
<li><strong>视觉契约</strong>：独立文档，作为主题附件提供。</li>
<li><strong>设计系统文档</strong>：自动生成的 <code>DESIGN_SYSTEM.md</code>，包含完整色板、海拔系统、WCAG 对比度数据，以及「变量选择协议」——<strong>所有颜色必须经过「原始值 → 语义层 → 组件层」的传递链条，任何跨层直接引用都是架构污染</strong>。</li>
</ul>

<h3 id="3-2-社区互动">3.2 社区互动</h3>

<ul>
<li><strong>GitHub 开源</strong>：公开源码，接受 PR。</li>
<li><strong>反馈渠道</strong>：鼓励用户反馈校准体验，持续优化视觉契约。</li>
</ul>

<h3 id="3-3-生态集成">3.3 生态集成</h3>

<ul>
<li><strong>Better Comments 预设</strong>：官方配色<strong>内置</strong>主题，零配置开箱即用（双源消除机制见<a href="./create-vscode-theme-build-system">构建体系</a>）。</li>
<li><strong>终端 ANSI 色同步</strong>：16 色 ANSI 配色映射到主题语义色，消除编辑器与终端的视觉割裂。</li>
<li><strong>跨平台令牌资产</strong>：自动导出 CSS / SCSS / TypeScript 三种令牌（生成方式见<a href="./create-vscode-theme-build-system">构建体系</a>），供博客、文档站、UI 组件库直接引用——一套颜色贯穿所有产品。</li>
</ul>

<hr>

<h2 id="第四部分-总结与展望">📌 第四部分：总结与展望</h2>

<p>至此，我们的五部曲系列已全部完成。回顾这段旅程：</p>

<ul>
<li><strong>VS Code 主题</strong>：你从手动 JSON 出发，创建并发布了第一个 VS Code 主题，掌握了核心机制与发布流程。</li>
<li><strong>主题工程化</strong>：你通过 YAML 模块化 + 构建脚本，让主题变得可维护、可自动构建。</li>
<li><strong>设计系统</strong>：你用 DTCG 三层架构管理颜色，通过语义层与重力补偿构建了昼夜双变体。</li>
<li><strong>构建体系</strong>：你构建了可测试、可验证的构建脚本架构，让质量保障自动化。</li>
<li><strong>品牌生态</strong>：你将主题升华为设计系统，用设计哲学、视觉契约和跨平台资产建立了完整的品牌生态。</li>
</ul>

<p><strong>v2.6.0 正是这套系统的实践成果</strong>——所有颜色经过工业级校验，布局令牌导出为 CSS 变量，设计文档自动生成，跨平台令牌同时产出 SCSS 与 TypeScript 格式。</p>

<p>Moongate 主题正是这一系列理念的实践成果。如果你希望亲身体验这套设计哲学，欢迎在 VS Code 市场中搜索 <strong>Moongate Theme</strong>，或通过以下链接探索：</p>

<ul>
<li><a href="https://marketplace.visualstudio.com/items?itemName=yuelinghuashu.moongate-theme" target="_blank">VS Code 市场页面</a></li>
<li><a href="https://github.com/yuelinghuashu/moongate-theme" target="_blank">GitHub 仓库</a></li>
</ul>

<p>如果你按照本系列的方法创建了自己的主题，或者有任何问题与想法，欢迎在评论区分享。代码世界那么大，愿你的主题也能被看见。</p>

<blockquote>
<p>💡 另外，如果你读完本系列，希望有一个<strong>可以直接 fork 的工程模板</strong>——无论是想快速替换配色得到自己的主题，还是想要一份干净的体系代码作为参照——欢迎在评论区告诉我们你的偏好。这将帮助我们决定是否、以及以何种形态提供一个 starter 模板。</p>
</blockquote>

<hr>

<h2 id="附-版本与维护策略">📌 附：版本与维护策略</h2>

<p>本系列文章描述的是 <strong>Moongate v2.6.0</strong> 的真实状态。为了让读者对文章的时效性有合理预期，这里说明系列的维护策略：</p>

<h3 id="版本锁定">版本锁定</h3>

<ul>
<li>本系列所有色值、scope、脚本示例均以 <strong>Moongate v2.6.0</strong> 为准。如果你安装的主题版本不同，部分细节（如海拔色值、语言规则）可能与文章存在出入。</li>
<li>系列导航与每篇文章开头的「对应 Moongate v2.6.0」标注，就是版本锁定的标识。</li>
</ul>

<h3 id="更新节奏">更新节奏</h3>

<ul>
<li><strong>主要版本更新</strong>（如 v3.0 引入新的架构变化）时，系列文章会同步修订，确保读者学到的是当前最佳实践。</li>
<li><strong>小版本更新</strong>（如新增语言支持、微调配色）不逐篇回改正文，通过项目的 <code>CHANGELOG.md</code> 记录。读者可以从更新日志追踪这些增量变化。</li>
</ul>

<h3 id="兼容性考虑">兼容性考虑</h3>

<ul>
<li><strong>VS Code 版本</strong>：<code>package.json</code> 中的 <code>engines</code> 字段（当前 <code>^1.130.0</code>）决定了 <code>workbench.yaml</code> 可用的 UI 键范围。如果 VS Code 后续新增了大量 UI 键，主题工程可能需要相应扩展——这正是工程化体系的意义所在。</li>
<li><strong>DTCG 标准</strong>：DTCG 规范仍在演进。Moongate 的策略是保持「原始值 → 语义层 → 组件层」三层架构的稳定，在各层内部拥抱规范变化。</li>
</ul>

<h3 id="读者如何跟进">读者如何跟进</h3>

<ul>
<li>关注 <a href="https://github.com/yuelinghuashu/moongate-theme" target="_blank">GitHub 仓库</a> 的 CHANGELOG，了解版本演进。</li>
<li>如果你按照本系列复刻了自己的主题工程，遇到与文章不符之处，欢迎在评论区反馈，这有助于我们校准文档与代码的一致性。</li>
</ul>

<hr>

<p><a href="#">⬆ 返回顶部</a></p>

<hr>

<p><em>本文是 VS Code 主题开发系列「从主题到品牌」的收官之作。五篇连读，助你从零成长为设计系统工程师。</em></p>
]]></content:encoded>
      <description><![CDATA[将主题升华为设计系统——定义设计哲学、建立视觉契约、提供显示器校准指南，让主题从代码工具进化为可复用的品牌资产，连接社区与产品生态。]]></description>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建体系：可测试、可验证的工程实践]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-build-system</link>
      <guid isPermaLink="true">https://moongate.top/docs/create-vscode-theme-build-system</guid>
      <pubDate>Thu, 06 Aug 2026 06:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>在<a href="./create-vscode-theme-design-system">设计系统</a>中，我们已经拥有了一套基于 DTCG 三层架构的主题生产系统，构建脚本能自动生成深色/浅色双主题，并能导出 CSS 变量。</p>

<p>但随着语言数量增长到 15 种、构建脚本功能越来越复杂，新的问题浮现了：</p>

<ol>
<li><strong>构建脚本本身缺乏架构</strong>——所有逻辑堆在 <code>build.js</code> 一个文件里，难以测试和维护。</li>
<li><strong>质量无法自动保证</strong>——颜色是否符合 WCAG 对比度标准？引用的变量是否都存在？有没有产生「组件层直接引用原始值」的架构污染？</li>
<li><strong>语言 scope 无法验证</strong>——写了 20 条语言规则，怎么确认每个 scope 真的存在于 VS Code 的 TextMate 语法中？「规则写了对不上」如何自动化检测？</li>
<li><strong>产物只有主题 JSON 和 CSS</strong>——能否进一步导出 SCSS、TypeScript 等更多格式，让设计资产在更多平台复用？</li>
</ol>

<p>本篇将回答这些问题，把构建脚本从「能用」升级为「工业级」——<strong>一套可测试、可验证、可维护的工程基础设施</strong>。</p>

<blockquote>
<p>🗺️ <strong>本篇路线图</strong>：五个步骤，层层递进——</p>

<ol>
<li><strong>架构</strong>：把单体脚本拆成可维护的模块（<code>一</code>）</li>
<li><strong>验证</strong>：让构建过程自我证明——质量校验与 scope 验证（<code>二</code>、<code>四</code>）</li>
<li><strong>优化</strong>：让生成的产物更精简（<code>三</code>）</li>
<li><strong>测试</strong>：让构建系统自身可回归验证（<code>五</code>）</li>
<li><strong>发布</strong>：把验证串进生态整合与交付链路（<code>六</code>、<code>七</code>、<code>八</code>）</li>
</ol>

<p>篇幅较长，建议先通读一遍建立全局认知，再结合自己的项目逐节实践——每节结尾都有与下一节的衔接说明。</p>

<p>💡 <strong>提示</strong>：本篇内容涉及较深的工程实践，建议先完成前三篇再阅读。如果你希望先了解「这套体系支撑起什么样的品牌生态」，也可以先读第五篇再回来。</p>
</blockquote>

<hr>

<h2 id="一-模块化架构-从单体脚本到-scripts-lib">📁 一、模块化架构：从单体脚本到 <code>scripts/lib/</code></h2>

<p>当构建脚本超过 200 行时，它本身也需要架构。Moongate 将构建系统拆分为 <code>scripts/lib/</code> 下的多个单职责模块：</p>

<pre><code class="language-text">your-theme/
├── scripts/
│   ├── build.js                     # 主流程（编排者，不包含具体实现）
│   ├── verify-scopes.js             # scope 验证 CLI
│   ├── generate-better-comments.js  # Better Comments 配置生成器
│   └── lib/
│       ├── config.js                # 路径配置
│       ├── tokens.js                # 令牌解析（resolveTokens、replaceVariables）
│       ├── utils.js                 # 通用工具（safeLoadYaml、normalizeHex 等）
│       ├── validators.js            # 质量验证（WCAG 对比度、结构验证、未使用令牌检测）
│       ├── optimizers.js            # 输出优化（token 合并、语义色精简）
│       ├── generators.js            # 多格式产物生成（CSS/SCSS/TS/设计文档）
│       └── scope-validator.js       # scope 验证逻辑
├── src/
│   ├── core/
│   │   ├── primitives/colors.yaml
│   │   ├── semantics/dark.yaml + light.yaml
│   │   └── layout.yaml
│   ├── languages/*.yaml
│   ├── workbench.yaml
│   ├── semantic.yaml
│   └── special/better-comments.yaml
├── themes/                          # 生成产物
├── docs/DESIGN_SYSTEM.md            # 自动生成的文档
├── test/*.test.js                   # 自动化测试
└── package.json
</code></pre>

<p>各模块的职责：</p>

<table>
<thead>
<tr>
<th>模块</th>
<th>职责</th>
<th>关键导出</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>config.js</code></td>
<td>集中管理所有文件路径</td>
<td><code>PATHS</code>、<code>ROOT_DIR</code></td>
</tr>

<tr>
<td><code>tokens.js</code></td>
<td>令牌解析与变量替换</td>
<td><code>resolveTokens</code>（<code>{token}</code> 层间引用）、<code>replaceVariables</code>（<code>${var}</code> 变量替换）、<code>detectPrimitiveReference</code>（架构污染检测）</td>
</tr>

<tr>
<td><code>utils.js</code></td>
<td>通用工具函数</td>
<td><code>safeLoadYaml</code>、<code>normalizeHex</code>、<code>detectDuplicateColors</code>、<code>getThemeInfo</code></td>
</tr>

<tr>
<td><code>validators.js</code></td>
<td>质量验证</td>
<td><code>checkContrast</code>（WCAG）、<code>validateThemeStructure</code>、<code>detectUnusedPrimitives</code></td>
</tr>

<tr>
<td><code>optimizers.js</code></td>
<td>输出精简</td>
<td><code>mergeTokenColors</code>、<code>optimizeSemanticTokenColors</code></td>
</tr>

<tr>
<td><code>generators.js</code></td>
<td>多格式产物</td>
<td><code>generateColorCss</code>、<code>generateLayoutCss</code>、<code>generateScssTokens</code>、<code>generateTsTokens</code>、<code>generateDesignSystemDoc</code></td>
</tr>

<tr>
<td><code>scope-validator.js</code></td>
<td>scope 验证逻辑</td>
<td><code>verifyAllScopes</code>、<code>formatVerificationResult</code></td>
</tr>
</tbody>
</table>

<h3 id="为什么用-esm-而不是-commonjs">为什么用 ESM 而不是 CommonJS？</h3>

<ul>
<li>现代 Node.js（≥ 14）原生支持 ESM，无需额外构建工具。</li>
<li><code>import</code> / <code>export</code> 是静态的，工具可以静态分析依赖关系，更容易重构和调试。</li>
<li>项目 <code>package.json</code> 中声明 <code>&quot;type&quot;: &quot;module&quot;</code>，所有 <code>.js</code> 文件默认视为 ESM。</li>
</ul>

<p><code>build.js</code> 作为主流程，只负责<strong>编排</strong>——加载模块、调用函数、捕获错误，不包含具体实现。</p>

<blockquote>
<p>📌 <strong>与上一篇的分工</strong>：<code>resolveTokens</code>、<code>replaceVariables</code> 的完整实现在<a href="./create-vscode-theme-design-system">设计系统</a>中已经见过，本篇<strong>不再重复</strong>。你要关注的是四个<strong>新增</strong>模块：<code>validators</code>（质量验证）、<code>optimizers</code>（输出精简）、<code>generators</code>（多格式产物）、<code>scope-validator</code>（scope 验证）——数据流地图里的每一步，都会落到这四个模块上。</p>
</blockquote>

<p>在阅读下面的代码之前，先建立一张「数据流地图」——每个关键步骤输入什么、输出什么：</p>

<table>
<thead>
<tr>
<th>步骤</th>
<th>输入</th>
<th>输出</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>loadPrimitives()</code></td>
<td><code>primitives/colors.yaml</code></td>
<td>标准化后的原始色值字典</td>
</tr>

<tr>
<td><code>resolveTokens()</code></td>
<td>原始值 + <code>semantics/*.yaml</code></td>
<td>每个变体的最终语义色值字典</td>
</tr>

<tr>
<td><code>loadTokenColors()</code></td>
<td><code>languages/</code> + <code>special/</code> 下的规则</td>
<td>合并后的 tokenColors 原始规则</td>
</tr>

<tr>
<td><code>replaceVariables()</code></td>
<td>规则文件 + 语义色值字典</td>
<td>变量已替换为实际色值的规则</td>
</tr>

<tr>
<td><code>mergeTokenColors()</code> / <code>optimizeSemanticTokenColors()</code></td>
<td>替换后的规则</td>
<td>精简后的产物</td>
</tr>

<tr>
<td><code>generate*()</code></td>
<td>最终色值字典 + 布局令牌</td>
<td>CSS / SCSS / TS 令牌与设计文档</td>
</tr>
</tbody>
</table>
<p>带着这张地图读代码，你会更清楚每个模块函数在整条流水线中的位置。</p>

<pre><code class="language-javascript">// scripts/build.js（主流程精简示意）
import { PATHS } from &quot;./lib/config.js&quot;
import { ensureFileExists, safeLoadYaml } from &quot;./lib/utils.js&quot;
import { resolveTokens, replaceVariables } from &quot;./lib/tokens.js&quot;
import {
  detectUnusedPrimitives,
  validateThemeStructure,
  checkContrast,
} from &quot;./lib/validators.js&quot;
import {
  mergeTokenColors,
  optimizeSemanticTokenColors,
} from &quot;./lib/optimizers.js&quot;
import {
  generateColorCss,
  generateLayoutCss,
  generateDesignSystemDoc,
  generateScssTokens,
  generateTsTokens,
} from &quot;./lib/generators.js&quot;

function main() {
  console.log(&quot;🚀 开始构建主题 (DTCG 标准 + 工业级质检)...\n&quot;)
  try {
    // 1. 检查必要文件
    // 2. 加载并标准化原始色值
    // 3. 生成布局 CSS
    // 4. 加载公共规则（workbench + semantic）
    // 5. 加载语言与特殊规则
    // 6. 扫描语义文件并检测未使用原始值
    // 7. 为每个语义层文件构建主题
    // 8. 生成 CSS 变量、跨平台令牌和设计系统文档
    console.log(&quot;\n🎉 所有主题构建完毕！&quot;)
  } catch (err) {
    console.error(err.message)
    process.exit(1)
  }
}

main()
</code></pre>

<h3 id="核心原则">核心原则</h3>

<p>每个函数只做一件事。<code>main()</code> 中每个步骤对应一个命名清晰的函数（<code>loadPrimitives()</code>、<code>loadCommonRules()</code>、<code>loadTokenColors()</code>……），调用者一眼就能看出构建流程的每一步在做什么。</p>

<p>模块化的骨架搭好了，接下来要解决真正的工程问题：<strong>构建过程必须能够自我证明</strong>。</p>

<hr>

<h2 id="二-质量验证-让构建过程-自我证明">🛡️ 二、质量验证：让构建过程「自我证明」</h2>

<p>工业级构建脚本的核心特征：<strong>它不仅仅生成文件，还会验证生成的文件是否正确</strong>。验证失败时中断构建，让错误在发布之前就被发现。</p>

<h3 id="2-1-wcag-对比度自动校验">2.1 WCAG 对比度自动校验</h3>

<p>主题的每个颜色都必须在背景上清晰可读。Moongate 为关键文本角色设置了对 <code>editor.background</code> 的最低对比度要求：</p>

<table>
<thead>
<tr>
<th>角色</th>
<th>最低对比度</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>text</code>（正文）</td>
<td>4.5:1</td>
<td>WCAG AA</td>
</tr>

<tr>
<td><code>textDim</code>（次要文字）</td>
<td>4.0:1</td>
<td>略低于 AA，权衡视觉层次</td>
</tr>

<tr>
<td><code>textMuted</code>（辅助文字）</td>
<td>3.0:1</td>
<td>大字号/辅助信息可用</td>
</tr>
</tbody>
</table>
<p>校验决策可用一棵简单的树来表达：</p>

<pre><code>某个语义色 vs 背景
   ├─ ratio &lt; 3.0        → ❌ 构建中断（任何角色都不合格）
   ├─ 3.0 ≤ ratio &lt; 4.0  → ⚠️ 仅 textMuted 可接受（其余失败）
   ├─ 4.0 ≤ ratio &lt; 4.5  → ⚠️ 仅 textDim/comment 可接受（text 失败）
   └─ ratio ≥ 4.5        → ✅ 全部通过（达到 WCAG AA）
</code></pre>

<pre><code class="language-javascript">// scripts/lib/validators.js（简化）
import wcag from &quot;wcag-contrast&quot;

export function checkContrast(color1, color2, role, themeType) {
  if (!color1 || !color2) return
  const ratio = wcag.hex(color1, color2)

  let minRatio = 4.5
  if (role === &quot;textDim&quot; || role === &quot;comment&quot;) minRatio = 4.0
  if (role === &quot;textMuted&quot;) minRatio = 3.0

  if (ratio &lt; minRatio) {
    if (role === &quot;textMuted&quot;) {
      console.warn(
        `⚠️ 对比度略低: ${themeType} · ${role} = ${ratio.toFixed(2)}:1`,
      )
    } else {
      throw new Error(
        `❌ 对比度不足: ${themeType} · ${role} = ${ratio.toFixed(2)}:1` +
          `\n   WCAG 要求 ≥${minRatio}:1`,
      )
    }
  } else {
    console.log(`✅ ${themeType} · ${role}: ${ratio.toFixed(2)}:1`)
  }
}
</code></pre>

<p>构建成功时，你会看到类似输出：</p>

<pre><code>✅ dark · text: 14.48:1
✅ dark · textDim: 12.02:1
✅ dark · textMuted: 6.96:1
✅ light · text: 17.08:1
✅ light · textDim: 7.25:1
✅ light · textMuted: 7.25:1
</code></pre>

<h4 id="关键设计">关键设计</h4>

<p><code>textDim</code> 和 <code>textMuted</code> 使用<strong>阶梯式标准</strong>而非统一 4.5:1——因为「视觉退后」本身就是设计意图，只要保持最低可读性即可。如果所有辅助文字都强制 4.5:1，注释和辅助信息就无法在视觉上「退后」了。</p>

<h3 id="2-2-结构验证-生成的文件必须自洽">2.2 结构验证：生成的文件必须自洽</h3>

<p><code>validateThemeStructure</code> 验证生成的主题 JSON：</p>

<ul>
<li>必须包含 <code>name</code>、<code>type</code>、<code>colors</code>、<code>tokenColors</code>、<code>semanticTokenColors</code> 五个必需键。</li>
<li><code>colors</code> 不能为空对象，且所有值必须是合法的 6 位或 8 位十六进制色值。</li>
<li><code>tokenColors</code> 必须是数组。</li>
<li><strong>不能存在未解析的 <code>${var}</code> 或 <code>{token}</code> 残留</strong>——如果某个变量因为拼写错误没有被替换，这里会直接报错并中断构建。</li>
</ul>

<p>验证失败时，抛出 <code>ThemeValidationError</code> 并携带是哪个输出文件出错的信息，错误处理统一由主流程捕获，而不是散落在各个函数里。</p>

<h3 id="2-3-循环引用检测">2.3 循环引用检测</h3>

<p>语义层可能引用原始值，原始值理论上也可能引用另一个原始值。如果 <code>a → b → a</code> 形成循环，<code>resolveTokens</code> 会无限递归。</p>

<p><code>tokens.js</code> 的做法：<strong>设定深度上限（20 层），超过则抛出 <code>[ENGINEERING_FATAL]</code> 错误并输出引用链</strong>：</p>

<pre><code class="language-javascript">export function resolveTokens(obj, tokenMap, depth = 0, path = []) {
  const MAX_DEPTH = 20
  if (depth &gt; MAX_DEPTH) {
    throw new Error(`[ENGINEERING_FATAL] 令牌循环引用检测: ${path.join(&quot; → &quot;)}`)
  }
  // ...
}
</code></pre>

<p>20 层的上限远高于正常的令牌引用深度（正常不会超过 2-3 层），因此一旦触发，<strong>几乎可以确定是循环引用</strong>。</p>

<h3 id="2-4-重复色值与未使用令牌检测">2.4 重复色值与未使用令牌检测</h3>

<ul>
<li><strong><code>detectDuplicateColors</code></strong>：检测原始值中是否有两个不同名字指向同一个色值。这不是错误（有时同名色值用于语义区分），但值得提醒——可能是命名混乱的信号。</li>
<li><strong><code>detectUnusedPrimitives</code></strong>：检测哪些原始值没有被任何语义层引用。未被引用的原始值可能是「死代码」，也提示语义层可能存在缺口。</li>
</ul>

<h3 id="2-5-架构污染检测">2.5 架构污染检测</h3>

<p>上一篇文章介绍过：颜色必须经历「原始值 → 语义层 → 组件层」的传递链条，任何跨层直接引用都是架构污染。</p>

<p><code>tokens.js</code> 中的 <code>detectPrimitiveReference</code> 会在构建时检测<strong>组件层是否直接引用了原始值</strong>（如 <code>workbench.yaml</code> 中出现 <code>editor.background: &quot;${blue-500}&quot;</code> 而非 <code>${surfaceGround}</code>），并给出警告：</p>

<pre><code>[架构提醒] workbench 中直接引用了原始值 &quot;blue-500&quot;，建议通过语义层引用。
</code></pre>

<p>这个功能让「架构规范」从口头约定变成了<strong>可自动检查的工程约束</strong>。</p>

<p>构建必须正确，但正确还不够——<strong>生成的产物还要精简</strong>，否则文件会随语言增多而膨胀。</p>

<hr>

<h2 id="三-输出优化-让生成的-json-更精简">⚙️ 三、输出优化：让生成的 JSON 更精简</h2>

<p>工业级构建不仅要「生成正确」，还要「生成精致」。Moongate 通过两个优化步骤，让最终主题 JSON 大幅精简。</p>

<h3 id="3-1-mergetokencolors-合并相同样式的规则">3.1 <code>mergeTokenColors</code>：合并相同样式的规则</h3>

<p>不同语言中经常有为不同 scope 分配<strong>完全相同样式</strong>的规则。例如：</p>

<pre><code class="language-yaml"># python.yaml
- name: Python F-string Expression
  scope: [&quot;meta.fstring.python&quot;]
  settings: { foreground: &quot;#7dd3fc&quot; }

# jsx.yaml
- name: JSX Expression Braces
  scope: [&quot;meta.jsx.expression&quot;]
  settings: { foreground: &quot;#7dd3fc&quot; }
</code></pre>

<p>这两个规则的颜色相同，完全可以合并为一条规则、两个 scope。<code>mergeTokenColors</code> 自动完成这件事：</p>

<ul>
<li>将 <code>settings</code> 序列化为<strong>稳定键</strong>（按键名排序，避免 <code>{foreground, fontStyle}</code> 和 <code>{fontStyle, foreground}</code> 被当作不同规则）。</li>
<li>相同样式的 rule 合并，scope 聚合为数组。</li>
<li>结果按 scope 数量降序排序（更具体的规则在前）。</li>
</ul>

<p>实际效果：Moongate v2.4.0 中，<code>tokenColors</code> 规则数从约 <strong>89 条精简至约 34 条（减少 62%）</strong>，主题 JSON 体积减少约 16%。</p>

<h3 id="3-2-optimizesemantictokencolors-删除冗余-foreground">3.2 <code>optimizeSemanticTokenColors</code>：删除冗余 foreground</h3>

<p>VS Code 的语义角色支持<strong>父级继承</strong>：<code>function.declaration</code> 会自动继承 <code>function</code> 的样式，除非被显式覆盖。</p>

<p>因此，当 <code>function.declaration</code> 的 <code>foreground</code> 与父级 <code>function</code> 完全相同时，可以安全删除 <code>foreground</code>，只保留额外的 <code>fontStyle</code>：</p>

<pre><code class="language-javascript">// 优化前
{
  &quot;function&quot;: &quot;#87cefa&quot;,
  &quot;function.declaration&quot;: {
    &quot;foreground&quot;: &quot;#87cefa&quot;,  // 冗余！与父级相同
    &quot;fontStyle&quot;: &quot;bold&quot;
  }
}

// 优化后
{
  &quot;function&quot;: &quot;#87cefa&quot;,
  &quot;function.declaration&quot;: {
    &quot;fontStyle&quot;: &quot;bold&quot;  // VS Code 自动继承 function 的颜色
  }
}
</code></pre>

<p>这个优化需要小心：只有当父子关系确实存在、且 foreground 确实相同时才安全。<code>optimizeSemanticTokenColors</code> 精确实现这个逻辑，并在删除时计数输出。</p>

<p>输出精简了，还藏着一个更深的问题：<strong>规则本身对不对</strong>？scope 是不是真的存在？</p>

<hr>

<h2 id="四-scope-验证-让-规则写了对不上-成为历史">🔍 四、Scope 验证：让「规则写了对不上」成为历史</h2>

<p>主题开发中最令人沮丧的问题之一：<strong>精心编写的语言规则，在代码里却不生效</strong>。</p>

<p>原因通常是：规则里的 <code>scope</code> 并不存在于 VS Code 实际使用的 TextMate 语法中。每个语言（Python、Go、Rust……）的 scope 是<strong>由语法文件定义的</strong>，不同语言、不同版本之间可能有巨大差异。手动对照语法文档编写 scope 非常容易出错。</p>

<p>Moongate 的解决方案是 <strong><code>scripts/verify-scopes.js</code></strong>——自动解析 VS Code 内置的 TextMate 语法文件，比对语言配置中的每个 scope，找出「写了却永远不生效」的死规则。</p>

<h3 id="4-1-工作原理">4.1 工作原理</h3>

<pre><code class="language-javascript">// scripts/verify-scopes.js（CLI 入口）
import {
  verifyAllScopes,
  formatVerificationResult,
} from &quot;./lib/scope-validator.js&quot;

const result = verifyAllScopes({ verbose: true })

console.log(&quot;🔍 验证语言配置中的 scope...\n&quot;)
process.stdout.write(formatVerificationResult(result))

if (!result.isValid) {
  console.error(`\n❌ 发现 ${result.totalIssues} 个 scope 不匹配，验证失败！`)
  process.exit(1)
}
</code></pre>

<p><code>scope-validator.js</code> 的核心流程：</p>

<ol>
<li><strong>定位语法源</strong>：在 VS Code 安装目录中找到对应语言的 TextMate 语法 JSON 文件（如 <code>python.tmLanguage.json</code>）。</li>
<li><strong>提取全部作用域</strong>：遍历语法文件中的 <code>patterns</code>、<code>captures</code>、<code>repository</code> 等结构，收集该语言所有可用的 scope 列表。</li>
<li><strong>比对语言规则</strong>：将 <code>src/languages/*.yaml</code> 中定义的每个 scope 与语法中实际存在的 scope 比对。</li>
<li><strong>输出报告</strong>：列出每个不匹配的 scope、所在文件、是哪个语言规则定义的。</li>
</ol>

<h3 id="4-2-它发现了什么">4.2 它发现了什么？</h3>

<p>在 Moongate v2.6.0 中，<code>verify-scopes.js</code> 帮助修复了 <strong>8 个语言文件</strong>的 scope 问题。其中最有代表性的几个：</p>

<table>
<thead>
<tr>
<th>语言</th>
<th>修复前（错误的 scope）</th>
<th>修复后（正确的 scope）</th>
</tr>
</thead>

<tbody>
<tr>
<td>Rust</td>
<td><code>support.macro.rust</code></td>
<td><code>entity.name.function.macro.rust</code></td>
</tr>

<tr>
<td>Rust</td>
<td><code>lifetime</code></td>
<td><code>entity.name.type.lifetime.rust</code></td>
</tr>

<tr>
<td>Go</td>
<td><code>entity.name.package.go</code></td>
<td><code>keyword.package.go</code></td>
</tr>

<tr>
<td>Python</td>
<td><code>meta.decorator.python</code></td>
<td><code>meta.function.decorator.python</code></td>
</tr>

<tr>
<td>Markdown</td>
<td><code>heading.1.markdown</code> 等</td>
<td><code>markup.heading.markdown</code> 等</td>
</tr>
</tbody>
</table>
<p>更关键的是，<strong>验证工具可以防止回归</strong>——每次修改语言规则后运行一次，立刻知道哪些 scope 是「写了但永远不会生效」的。</p>

<h3 id="4-3-在-ci-中使用">4.3 在 CI 中使用</h3>

<p><code>verify-scopes.js</code> 在发现错误时以退出码 1 结束，因此可以无缝集成到 CI 流程中：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;test:scopes&quot;: &quot;node scripts/verify-scopes.js&quot;
  }
}
</code></pre>

<p>CI 中运行 <code>pnpm test:scopes</code>，一旦有人提交了错误的 scope，构建立即失败。</p>

<p>验证工具越来越多，<strong>验证工具自身也需要被验证</strong>——这就是自动化测试的价值。</p>

<hr>

<h2 id="五-自动化测试-让构建系统可回归验证">🧪 五、自动化测试：让构建系统可回归验证</h2>

<p>当构建系统承担了「生成全部产物 + 质量验证 + 架构检查」的重任后，构建系统<strong>自身</strong>也需要测试来防止回归。</p>

<p>Moongate 使用 Node.js 内置的 <code>node --test</code> 测试运行器，不需要额外安装测试框架：</p>

<h3 id="5-1-测试覆盖范围">5.1 测试覆盖范围</h3>

<pre><code>test/
├── tokens.test.js        # 令牌解析、变量替换、循环检测
├── validators.test.js    # WCAG 对比度、结构验证、架构污染检测
├── optimizers.test.js    # token 合并、语义色精简
├── generators.test.js    # CSS/SCSS/TS/文档生成器
├── utils.test.js         # normalizeHex、duplicateColors 等工具
├── scope-validator.test.js # scope 验证逻辑
├── better-comments.test.js # Better Comments 生成器
├── theme-output.test.js  # 构建输出的主题 JSON 完整性
└── helpers.js            # 测试辅助（捕获 console、断言抛错）
</code></pre>

<p>85 个测试覆盖三大类：</p>

<ol>
<li><strong>纯函数逻辑</strong>：<code>normalizeHex</code> 对 3 位/4 位/8 位色值的处理、<code>resolveTokens</code> 的循环检测、<code>mergeTokenColors</code> 的稳定键等。</li>
<li><strong>边界条件</strong>：非法色值抛错、未定义变量警告、透明度后缀的多种边界情况。</li>
<li><strong>产物一致性</strong>：生成的 CSS 变量与语义层一一对应、Better Comments 配置与深色语义层色值同步。</li>
</ol>

<h3 id="5-2-测试辅助-捕获-console-输出">5.2 测试辅助：捕获 console 输出</h3>

<p>构建工具大量使用 <code>console.log</code> / <code>console.warn</code> 输出进度和警告。测试时，<code>test/helpers.js</code> 提供统一的辅助函数来捕获这些输出并断言：</p>

<pre><code class="language-javascript">// test/helpers.js（示意）
export function captureConsole(fn) {
  const logs = []
  const originalLog = console.log
  const originalWarn = console.warn
  console.log = (...args) =&gt; logs.push({ type: &quot;log&quot;, args })
  console.warn = (...args) =&gt; logs.push({ type: &quot;warn&quot;, args })
  try {
    const result = fn()
    return { logs, result }
  } finally {
    console.log = originalLog
    console.warn = originalWarn
  }
}
</code></pre>

<p>这个模式的优雅之处：<strong>不需要 mock 每个函数</strong>——只要断言 <code>console.warn</code> 被调用过，且传入了包含「警告」关键词的参数，就能验证「未定义变量时会发出警告」这类行为。</p>

<h3 id="5-3-运行测试">5.3 运行测试</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;node --test \&quot;test/*.test.js\&quot;&quot;
  }
}
</code></pre>

<pre><code class="language-bash">pnpm test
</code></pre>

<p>输出示例：</p>

<pre><code>▶ tokens.test.js
  ✔ 解析令牌引用 {token}
  ✔ 替换变量 ${var} 支持透明度后缀
  ✔ 检测到循环引用时抛出错误
  ...
▶ validators.test.js
  ✔ 结构验证：缺少必需键时报错
  ✔ WCAG 对比度不足时抛出异常
  ...
✔ 85 tests passed
</code></pre>

<p>构建体系稳定之后，可以把这套能力伸向<strong>生态整合</strong>：让配色在插件生态里也不漂移。</p>

<hr>

<h2 id="六-better-comments-双源消除">🎨 六、Better Comments 双源消除</h2>

<p>主题的一个重要生态整合是 <strong>Better Comments</strong> 插件——它让注释中的 <code>TODO</code>、<code>FIXME</code>、<code>NOTE</code> 等标记拥有专属颜色。旧方案的问题在于：<strong>颜色存在两处</strong>（主题的语义层 + Better Comments 的 JSON 配置），修改一处后另一处容易忘改，导致配色漂移。</p>

<p>Moongate 的解法：<strong>Better Comments 配置由构建脚本自动生成</strong>，从深色语义层读取色值。</p>

<h3 id="6-1-单源真相">6.1 单源真相</h3>

<pre><code class="language-javascript">// scripts/generate-better-comments.js（核心逻辑）
import { resolveTokens } from &quot;./lib/tokens.js&quot;

// Better Comments 需要的语义变量 → tag 映射
const TAG_MAP = [
  { tag: &quot;TODO&quot;, semanticKey: &quot;warning&quot;, bold: true },
  { tag: &quot;FIXME&quot;, semanticKey: &quot;error&quot;, bold: true, italic: true },
  { tag: &quot;NOTE&quot;, semanticKey: &quot;highlight&quot;, italic: true },
  { tag: &quot;HACK&quot;, semanticKey: &quot;purple&quot;, bold: true },
  { tag: &quot;BUG&quot;, semanticKey: &quot;error&quot;, bold: true, underline: true },
  { tag: &quot;XXX&quot;, semanticKey: &quot;warning&quot;, bold: true },
]

// 从深色语义层解析每个 tag 对应的最终色值
const tags = TAG_MAP.map(({ tag, semanticKey, ...style }) =&gt; {
  const color = resolveTokens(darkSemantics[semanticKey], primitives)
  return { tag, color, ...style }
})

// 写入 extras/better-comments.json
</code></pre>

<p>当你在 <code>dark.yaml</code> 中调整 <code>warning</code> 的颜色时，运行 <code>pnpm run gen:better-comments</code>，<code>extras/better-comments.json</code> 自动同步。</p>

<h3 id="6-2-内置规则-零配置开箱即用">6.2 内置规则：零配置开箱即用</h3>

<p>除了生成独立预设，Moongate 还在 <code>src/special/better-comments.yaml</code> 中内置了 6 个特殊注释 scope 的规则（TODO、FIXME、NOTE、HACK、BUG、XXX）。<strong>安装 Moongate 主题后，Better Comments 插件自动使用官方配色，无需任何手动配置。</strong></p>

<p>这条「双通道」设计覆盖了两种用户：</p>

<ul>
<li><strong>主题用户</strong>：安装 Moongate 即获得完整配色（内置规则）。</li>
<li><strong>独立配置用户</strong>：不使用主题、只看注释配色的人，可以单独引入 <code>extras/better-comments.json</code>。</li>
</ul>

<p>两条通道都从语义层生成，<strong>不会产生双源漂移</strong>。</p>

<hr>

<h2 id="七-多格式产物生成-一套令牌-全平台复用">📦 七、多格式产物生成：一套令牌，全平台复用</h2>

<p>设计系统介绍了 CSS 变量导出，Moongate 进一步将语义层导出为<strong>四种格式</strong>，覆盖 Web、Sass 和 TypeScript 生态：</p>

<table>
<thead>
<tr>
<th>产物</th>
<th>格式</th>
<th>适用场景</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>themes/moongate-colors.css</code></td>
<td>CSS 变量</td>
<td>博客、组件库、任何 Web 项目</td>
</tr>

<tr>
<td><code>themes/moongate-layout.css</code></td>
<td>CSS 变量（布局）</td>
<td>间距、排版、断点、z-index</td>
</tr>

<tr>
<td><code>themes/_tokens.scss</code></td>
<td>SCSS</td>
<td>Sass 项目</td>
</tr>

<tr>
<td><code>themes/tokens.ts</code></td>
<td>TypeScript</td>
<td>前端框架项目</td>
</tr>
</tbody>
</table>

<h3 id="7-1-scss-令牌">7.1 SCSS 令牌</h3>

<pre><code class="language-scss">// themes/_tokens.scss（自动生成）
// 部分示意
$ui-colors-dark: (
  bg: #0f172a,
  primary: #3b82f6,
  surface-raised: #1a2538, // ...
);

$ui-colors-light: (
  bg: #f9fafb,
  primary: #0284c7,
  surface-raised: #ffffff, // ...
);

// 深色模式便捷变量
$ui-bg: #0f172a;
$ui-primary: #3b82f6;
</code></pre>

<h3 id="7-2-typescript-令牌">7.2 TypeScript 令牌</h3>

<pre><code class="language-typescript">// themes/tokens.ts（自动生成）
export interface MoongateTokens {
  dark: Record&lt;string, string&gt;
  light: Record&lt;string, string&gt;
}

export const tokens: MoongateTokens = {
  dark: {
    bg: &quot;#0f172a&quot;,
    primary: &quot;#3b82f6&quot;,
    // ...
  },
  light: {
    bg: &quot;#f9fafb&quot;,
    primary: &quot;#0284c7&quot;,
    // ...
  },
}

export default tokens
</code></pre>

<h3 id="7-3-设计系统文档自动生成">7.3 设计系统文档自动生成</h3>

<p>构建脚本还会自动生成 <code>docs/DESIGN_SYSTEM.md</code>——包含变量选择协议、原始色板预览、海拔系统表格、WCAG 对比度数据。这份文档<strong>不是手写的</strong>，而是每次构建时从真实数据生成，保证文档永远与代码一致。</p>

<hr>

<h2 id="八-ci-发布链路-错误在发布前被发现">🔧 八、CI/发布链路：错误在发布前被发现</h2>

<p>工业级工程的最后一块拼图：<strong>把验证融入发布流程</strong>。</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;node scripts/build.js&quot;,
    &quot;test&quot;: &quot;node --test \&quot;test/*.test.js\&quot;&quot;,
    &quot;test:scopes&quot;: &quot;node scripts/verify-scopes.js&quot;,
    &quot;gen:better-comments&quot;: &quot;node scripts/generate-better-comments.js&quot;,
    &quot;package&quot;: &quot;pnpm run build &amp;&amp; pnpm run gen:better-comments &amp;&amp; vsce package&quot;,
    &quot;publish&quot;: &quot;pnpm run build &amp;&amp; pnpm run gen:better-comments &amp;&amp; vsce publish&quot;,
    &quot;prepublishOnly&quot;: &quot;pnpm run build &amp;&amp; pnpm run gen:better-comments&quot;
  }
}
</code></pre>

<p>发布链路逐层把关：</p>

<pre><code>pnpm run package
  → pnpm run build              # 构建 + WCAG 校验 + 结构验证 + 架构检测
  → pnpm run gen:better-comments # 重新生成 Better Comments 配置
  → vsce package                # 打包 .vsix
</code></pre>

<ul>
<li>构建失败 → 不产生 <code>.vsix</code>。</li>
<li>对比度不足 → 构建中断，错误信息标明是哪个主题、哪个角色、差多少。</li>
<li>scope 错误 → <code>pnpm run test:scopes</code> 退出码 1，CI 拦截。</li>
</ul>

<p><strong>「发布前检查清单」从手动流程升级为自动化门禁</strong>——这正是工业级与手动的分水岭。</p>

<hr>

<h2 id="九-总结">📊 九、总结</h2>

<p>至此，你的主题构建系统已经完成了从「能用」到「工业级」的跃迁：</p>

<table>
<thead>
<tr>
<th>能力</th>
<th>主题工程化</th>
<th>构建体系</th>
</tr>
</thead>

<tbody>
<tr>
<td>模块化</td>
<td>单体 <code>build.js</code></td>
<td><code>scripts/lib/</code> 单职责模块</td>
</tr>

<tr>
<td>代码风格</td>
<td>CommonJS</td>
<td>ESM</td>
</tr>

<tr>
<td>WCAG 校验</td>
<td>❌</td>
<td>✅ 阶梯式对比度自动检查</td>
</tr>

<tr>
<td>结构验证</td>
<td>❌</td>
<td>✅ 未解析变量/令牌检测</td>
</tr>

<tr>
<td>循环引用</td>
<td>❌</td>
<td>✅ 深度上限 + 引用链输出</td>
</tr>

<tr>
<td>架构污染</td>
<td>❌</td>
<td>✅ 原始值直接引用警告</td>
</tr>

<tr>
<td>输出优化</td>
<td>❌</td>
<td>✅ token 合并 + 语义色精简</td>
</tr>

<tr>
<td>Scope 验证</td>
<td>❌</td>
<td>✅ <code>verify-scopes.js</code> 自动比对</td>
</tr>

<tr>
<td>自动化测试</td>
<td>❌</td>
<td>✅ 85 个测试（<code>node --test</code>）</td>
</tr>

<tr>
<td>多格式产物</td>
<td>CSS</td>
<td>CSS + SCSS + TypeScript + 设计文档</td>
</tr>

<tr>
<td>Better Comments</td>
<td>❌</td>
<td>✅ 自动生成 + 内置规则</td>
</tr>

<tr>
<td>发布门禁</td>
<td>手动检查</td>
<td>构建/测试/scope 验证自动拦截</td>
</tr>
</tbody>
</table>
<p>这套体系不仅是 Moongate 主题的生产工具，更是一个可以复用的<strong>设计系统工程模板</strong>。它验证了「零散的颜色 → 工程资产 → 全平台可复用」的完整路径。</p>

<p>但工程能力再强，也只是「怎么做」的问题。下一个问题同样重要：<strong>「为什么这样做」——设计哲学从何而来？如何让用户在不同硬件上获得一致的体验？如何让主题超越代码，成为品牌的组成部分？</strong></p>

<p>这正是系列的最后一篇——<strong>品牌生态</strong>要回答的。</p>

<p><a href="./create-vscode-theme-brand-ecosystem"><strong>品牌生态：设计哲学与视觉契约</strong></a></p>
]]></content:encoded>
      <description><![CDATA[从单体脚本到模块化工程体系——ESM 模块拆分、WCAG 对比度自动校验、scope 自动验证、自动化测试与多格式产物生成，让构建脚本自身成为一套可信赖的工程基础设施。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[设计系统：DTCG 三层架构与昼夜双变体]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-design-system</link>
      <guid isPermaLink="true">https://moongate.top/docs/create-vscode-theme-design-system</guid>
      <pubDate>Thu, 06 Aug 2026 04:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>在<a href="./create-vscode-theme-engineering">主题工程化</a>的结尾，我们指出了工程化方案的三个痛点：</p>

<ul>
<li><code>colors.yaml</code> 是一个<strong>扁平的变量池</strong>，颜色之间的层级关系完全靠命名约定，没有结构性的约束。</li>
<li>深浅两套主题需要两套颜色变量文件，而「同一角色在深色和浅色下应该保持色相一致、明度不同」这件事完全靠手动维护。</li>
<li>构建脚本只能做变量替换，不能自动校验颜色是否符合对比度标准。</li>
</ul>

<p>本篇将解决这些问题。我们将引入 <strong>DTCG（Design Tokens Community Group）设计令牌标准</strong>的三层架构，让颜色从「散乱的变量」升级为「有结构的工程资产」，并基于它构建深色/浅色双变体——这是主题迈向设计系统的关键一步。</p>

<hr>

<h2 id="一-核心思路-从-多主题-到-设计系统">🧱 一、核心思路：从「多主题」到「设计系统」</h2>

<p>一个优秀的主题不应只有一副面孔。提供深色/浅色双主题，不仅能覆盖更广泛的用户需求，更是主题迈向设计系统的关键一步。</p>

<p>但<strong>多主题的真正价值不是多几套颜色</strong>，而是：<strong>所有主题共享同一套规则，唯一不同的是颜色变量的具体值。</strong></p>

<ul>
<li>语言规则（<code>languages/*.yaml</code>）在所有变体中完全复用。</li>
<li>UI 颜色（<code>workbench.yaml</code>）和语义规则（<code>semantic.yaml</code>）只引用变量名，不写具体色值。</li>
<li>每个变体只提供一个「语义层」，定义每个语义角色在该变体下的具体颜色。</li>
</ul>

<p>要做到这一点，需要一套能让「颜色」成为结构化工程资产的管理体系——这就是 DTCG 三层架构。</p>

<hr>

<h2 id="二-dtcg-三层架构">🏗️ 二、DTCG 三层架构</h2>

<p>DTCG（Design Tokens Community Group）是 W3C 下属的行业标准组织，旨在为设计令牌（Design Tokens）建立统一格式。它的核心思想可以用一个简单的问题概括：<strong>一个颜色值，应该由谁来定义、被谁引用、以什么名称存在？</strong></p>

<p>Moongate 采用 DTCG 推荐的三层架构：</p>

<pre><code class="language-text">┌─────────────────────────────────────────────────┐
│  原始值层（Primitives）                           │
│  src/core/primitives/colors.yaml                 │
│  按色相-明度命名：blue-500、gray-900             │
└───────────────────────┬─────────────────────────┘
                        │ 语义层用 {token} 引用原始值
                        ▼
┌─────────────────────────────────────────────────┐
│  语义层（Semantics）                              │
│  src/core/semantics/dark.yaml + light.yaml       │
│  定义角色：primary、bg、surfaceGround            │
│  primary: &quot;{blue-500}&quot;                           │
└───────────────────────┬─────────────────────────┘
                        │ 组件层用 ${variable} 引用语义层
                        ▼
┌─────────────────────────────────────────────────┐
│  组件层（Components）                             │
│  src/workbench.yaml + semantic.yaml              │
│  映射 UI 元素                                    │
│  editor.background: &quot;${surfaceGround}&quot;           │
└─────────────────────────────────────────────────┘
</code></pre>

<h3 id="2-1-第一层-原始值-primitives">2.1 第一层：原始值（Primitives）</h3>

<p>原始值层是<strong>所有颜色的物理事实</strong>——不表达任何语义，只按色相和明度命名。</p>

<pre><code class="language-yaml"># src/core/primitives/colors.yaml
# ==================== 蓝色系 ====================
blue-500: &quot;#3b82f6&quot; # 深色主蓝
blue-600: &quot;#2563eb&quot; # 深色按钮悬停
blue-700: &quot;#0284c7&quot; # 浅色主蓝
blue-800: &quot;#0369a1&quot; # 浅色高亮/函数

# 发光蓝（特殊）
blue-glow: &quot;#7dd3fc&quot; # 深色高亮
blue-glow-dark: &quot;#87cefa&quot; # 深色函数

# ==================== 灰色阶（冷调基底）====================
gray-900: &quot;#0f172a&quot; # 深色编辑器背景
gray-850: &quot;#131c31&quot; # 深色卡片/浮层
gray-800: &quot;#1e293b&quot; # 深色侧边栏/代码块
gray-750: &quot;#252e40&quot; # 深色悬停背景
gray-700: &quot;#2d3748&quot; # 深色边框
# ... 完整的色相-明度阶梯
</code></pre>

<h4 id="命名规范">命名规范</h4>

<p><code>色相-明度</code>，例如 <code>blue-500</code>、<code>green-400</code>、<code>gray-900</code>。这样命名不是为了好看，而是为了让「同一个色相在不同明度下如何变化」这件事变得可追溯。</p>

<h4 id="原始值的价值">原始值的价值</h4>

<p>当你需要「给所有主题换一个更蓝的主色」时，只需要调整 <code>blue-500</code> 和 <code>blue-700</code> 两个原始值，所有引用它的语义色自动同步。</p>

<h3 id="2-2-第二层-语义层-semantics">2.2 第二层：语义层（Semantics）</h3>

<p>语义层定义<strong>角色</strong>——<code>primary</code>（主色）、<code>bg</code>（背景）、<code>surfaceGround</code>（地面层）——并为每个角色指派一个原始值。<strong>语义层不与任何 UI 元素绑定</strong>，它只回答一个问题：「主色应该是什么颜色？」</p>

<p>每个变体都有一个独立的语义层文件：</p>

<pre><code class="language-yaml"># src/core/semantics/dark.yaml（深色语义层）
# ==================== 月语义主色 ====================
primary: &quot;{blue-500}&quot;
success: &quot;{green-400}&quot;
warning: &quot;{yellow-400}&quot;
error: &quot;{red-400}&quot;
highlight: &quot;{blue-glow}&quot;

# 功能色（语法）
function: &quot;{blue-glow-dark}&quot;
operator: &quot;{gray-600}&quot;
comment: &quot;{gray-525}&quot;
variable: &quot;{gray-200}&quot;

# 海拔系统
surfaceGround: &quot;{gray-900}&quot;
surfaceRaised: &quot;{gray-850}&quot;
surfaceFloating: &quot;{gray-800}&quot;
surfaceTooltip: &quot;{gray-750}&quot;
</code></pre>

<pre><code class="language-yaml"># src/core/semantics/light.yaml（浅色语义层）
# 变量名与深色版完全一致，仅色值不同
# ==================== 月语义主色 ====================
primary: &quot;{blue-700}&quot;
success: &quot;{green-600}&quot;
warning: &quot;{yellow-700}&quot;
error: &quot;{red-700}&quot;
highlight: &quot;{blue-800}&quot;

# 功能色（语法）
function: &quot;{blue-800}&quot;
operator: &quot;{gray-600}&quot;
comment: &quot;{gray-600}&quot;
variable: &quot;{gray-900}&quot;

# 海拔系统
surfaceGround: &quot;{gray-50}&quot;
surfaceRaised: &quot;{white}&quot;
surfaceFloating: &quot;{gray-100}&quot;
surfaceTooltip: &quot;{gray-200}&quot;
</code></pre>

<h4 id="关键原则">关键原则</h4>

<p>所有变体的语义层变量名<strong>必须完全一致</strong>。这是规则复用的基础——<code>workbench.yaml</code> 和 <code>languages/*.yaml</code> 不需要知道当前是深色还是浅色，它们只引用 <code>${primary}</code>、<code>${surfaceRaised}</code>，具体值交给语义层决定。</p>

<h3 id="2-3-第三层-组件层-components">2.3 第三层：组件层（Components）</h3>

<p>组件层<strong>直接映射 UI 元素</strong>和语法角色。它只引用语义层变量，不写任何具体色值：</p>

<pre><code class="language-yaml"># src/workbench.yaml（组件层：UI 颜色）
editor.background: &quot;${surfaceGround}&quot;
editor.foreground: &quot;${text}&quot;
titleBar.activeBackground: &quot;${surfaceRaised}&quot;
titleBar.activeForeground: &quot;${text}&quot;
statusBar.background: &quot;${surfaceGround}&quot;
statusBar.foreground: &quot;${textDim}&quot;
sideBar.background: &quot;${surfaceRaised}&quot;
# ... 所有 UI 键
</code></pre>

<pre><code class="language-yaml"># src/semantic.yaml（组件层：语义高亮）
variable: &quot;${variable}&quot;
variable.readonly:
  foreground: &quot;${variableDim}&quot;
  fontStyle: &quot;italic&quot;
function: &quot;${function}&quot;
function.declaration:
  foreground: &quot;${function}&quot;
  fontStyle: &quot;bold&quot;
class: &quot;${warning}&quot;
# ... 语义规则
</code></pre>

<h3 id="2-4-两种引用语法-token-与-variable">2.4 两种引用语法：<code>{token}</code> 与 <code>${variable}</code></h3>

<p>在 Moongate 的架构中，两种「引用」有着完全不同的语义：</p>

<table>
<thead>
<tr>
<th>语法</th>
<th>含义</th>
<th>使用位置</th>
<th>示例</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>{token}</code></td>
<td><strong>层间引用</strong>：引用另一个令牌</td>
<td>语义层引用原始值</td>
<td><code>primary: &quot;{blue-500}&quot;</code></td>
</tr>

<tr>
<td><code>${variable}</code></td>
<td><strong>变量替换</strong>：构建时替换为最终色值</td>
<td>组件层引用语义层</td>
<td><code>editor.background: &quot;${surfaceGround}&quot;</code></td>
</tr>
</tbody>
</table>

<ul>
<li>语义层用 <code>{token}</code> 引用原始值，构建脚本递归解析令牌引用（支持循环检测，见构建体系）。</li>
<li>组件层用 <code>${variable}</code> 引用语义层变量，构建脚本替换为最终色值（支持透明度后缀，如 <code>${primary}20</code>）。</li>
</ul>

<p>这个分工不是形式主义——它让每个文件都清楚自己「属于哪一层、可以引用谁」。构建体系中我们会看到，构建脚本甚至能检测「组件层直接引用原始值」这种架构污染并发出警告。</p>

<hr>

<h2 id="三-昼夜双变体与重力补偿">⚖️ 三、昼夜双变体与重力补偿</h2>

<p>有了三层架构，深色/浅色双主题的构建就变得非常自然：<strong>所有规则文件完全复用，只有语义层不同</strong>。但语义层的色值不是随手填的——深色和浅色之间需要一套科学的映射规则。</p>

<h3 id="3-1-为什么浅色不是深色的-反相">3.1 为什么浅色不是深色的「反相」？</h3>

<p>很多新手以为浅色主题就是把深色主题的颜色反相。但这是完全错误的：</p>

<ul>
<li>深色背景上的亮色是<strong>「发光体」</strong>——它们通过「亮于背景」来获得存在感。</li>
<li>浅色背景上的暗色是<strong>「吸光体」</strong>——它们通过「暗于背景」来获得存在感。</li>
</ul>

<p>同样是 <code>primary</code>，在深色下用亮蓝 <code>#3b82f6</code>（明度 60%）很好看，但如果直接搬到白底上，就会因为和白色背景的对比度不足而「消失」。要实现视觉重量对等，必须进行<strong>重力补偿</strong>——保持色相不变，科学调整明度和饱和度。</p>

<h3 id="3-2-moongate-的补偿实例">3.2 Moongate 的补偿实例</h3>

<table>
<thead>
<tr>
<th>语义角色</th>
<th>深色版</th>
<th>浅色版</th>
<th>调整方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>主色（primary）</td>
<td><code>#3b82f6</code> (60% 明度)</td>
<td><code>#0284c7</code> (48% 明度)</td>
<td>蓝调不变，明度降低约 20%，适应白底</td>
</tr>

<tr>
<td>成功（success）</td>
<td><code>#34d399</code> (65%)</td>
<td><code>#059669</code> (40%)</td>
<td>绿色更沉稳，保证对比度</td>
</tr>

<tr>
<td>警告（warning）</td>
<td><code>#fbbf24</code> (75%)</td>
<td><code>#b45309</code> (35%)</td>
<td>从亮黄转为橙黄，避免在白底上「消失」</td>
</tr>

<tr>
<td>错误（error）</td>
<td><code>#f87171</code> (60%)</td>
<td><code>#b91c1c</code> (35%)</td>
<td>深红保持警示感</td>
</tr>
</tbody>
</table>

<h4 id="补偿规律">补偿规律</h4>

<ul>
<li><strong>色相（H）不变</strong>——这是语义一致性的核心。<code>primary</code> 永远是蓝色，用户在切换主题时不需要重新学习。</li>
<li><strong>明度（L）降低</strong>——深色版的亮色在白底上需要更暗才能达到同等对比度。通常降低 20-30%。</li>
<li><strong>饱和度（S）适当调整</strong>——深色背景上高饱和度产生舒适的「发光感」，但在浅色背景上可能刺眼，通常降低 10-20%。</li>
</ul>

<blockquote>
<p>💡 <strong>如何科学地确定补偿值</strong>：手动「凭感觉」调整明度效率低且不一致。推荐使用 <strong>HSL 颜色模型</strong>：把 HEX 转成 HSL，保持 H 不变，调整 L 和 S。可以用 <a href="https://gka.github.io/chroma.js/" target="_blank">chroma.js</a> 等工具程序化调整，例如：</p>

<pre><code class="language-javascript">const darkPrimary = chroma(&quot;#3b82f6&quot;)
const lightPrimary = darkPrimary.set(&quot;hsl.l&quot;, 0.48).set(&quot;hsl.s&quot;, 0.7)
</code></pre>
</blockquote>

<h3 id="3-3-海拔系统-为-ui-注入物理深度">3.3 海拔系统：为 UI 注入物理深度</h3>

<p>除了语法颜色，UI 界面也需要在深浅两套主题中保持一致的「空间感」。海拔系统通过定义多级背景明度阶梯，表达 UI 元素的物理深度：</p>

<ul>
<li>深色模式：海拔越高表面越亮（明度递增）。</li>
<li>浅色模式：海拔越高表面越亮（但用更高亮度的白色/浅色）。</li>
<li>每层阶梯的步长保持一致，形成平滑的层次感。</li>
</ul>

<h4 id="moongate-的四层海拔">Moongate 的四层海拔</h4>

<table>
<thead>
<tr>
<th>海拔层级</th>
<th>用途</th>
<th>深色模式</th>
<th>浅色模式</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>surfaceGround</code></td>
<td>底层背景（编辑器）</td>
<td><code>#0f172a</code></td>
<td><code>#f9fafb</code></td>
<td>基准层</td>
</tr>

<tr>
<td><code>surfaceRaised</code></td>
<td>侧边栏、活动栏、选项卡栏</td>
<td><code>#1a2538</code></td>
<td><code>#ffffff</code></td>
<td>深色 +5%，浅色纯白</td>
</tr>

<tr>
<td><code>surfaceFloating</code></td>
<td>面板、悬浮卡片、菜单</td>
<td><code>#25364a</code></td>
<td><code>#f1f5f9</code></td>
<td>再 +5%，浅色浅灰蓝</td>
</tr>

<tr>
<td><code>surfaceTooltip</code></td>
<td>提示框、弹窗</td>
<td><code>#2e3b4d</code></td>
<td><code>#e2e8f0</code></td>
<td>最高层</td>
</tr>
</tbody>
</table>

<blockquote>
<p>📌 <strong>注意</strong>：浅色模式采用「越高越亮」还是「越高越暗」是一个设计选择。Moongate 选择浅色模式下浮层比背景<strong>更亮</strong>（更白），形成「纸张层叠」的通透感；深色模式则采用「越高越亮」，让浮层从背景中微微隆起。两种模式都遵循「海拔越高越醒目」的物理隐喻。</p>
</blockquote>

<p>这种设计让侧边栏微微隆起，弹窗轻盈浮现，代码区沉静深邃——编辑器从平面走向立体。</p>

<hr>

<h2 id="四-构建脚本自动生成双主题">🔨 四、构建脚本自动生成双主题</h2>

<p>有了三层架构，构建脚本只需要做一件事：<strong>扫描 <code>semantics/</code> 目录，为每个语义层文件生成一个主题 JSON</strong>。</p>

<p>以 <code>scripts/build.js</code> 的简化版为例：</p>

<pre><code class="language-javascript">// scripts/build.js（简化版，完整版见构建体系）
import fs from &quot;node:fs&quot;
import path from &quot;node:path&quot;
import yaml from &quot;js-yaml&quot;

const ROOT_DIR = process.cwd()

// 路径配置
const PATHS = {
  primitives: path.join(ROOT_DIR, &quot;src&quot;, &quot;core&quot;, &quot;primitives&quot;, &quot;colors.yaml&quot;),
  semanticsDir: path.join(ROOT_DIR, &quot;src&quot;, &quot;core&quot;, &quot;semantics&quot;),
  workbench: path.join(ROOT_DIR, &quot;src&quot;, &quot;workbench.yaml&quot;),
  semantic: path.join(ROOT_DIR, &quot;src&quot;, &quot;semantic.yaml&quot;),
  langDir: path.join(ROOT_DIR, &quot;src&quot;, &quot;languages&quot;),
  outputDir: path.join(ROOT_DIR, &quot;themes&quot;),
}

// 加载原始值
const primitives = yaml.load(fs.readFileSync(PATHS.primitives, &quot;utf8&quot;))

// 解析令牌引用 {token}：语义层引用原始值
function resolveTokens(obj, tokenMap, depth = 0) {
  const MAX_DEPTH = 20
  if (depth &gt; MAX_DEPTH) {
    throw new Error(
      `[ENGINEERING_FATAL] 令牌循环引用检测: ${JSON.stringify(obj)}`,
    )
  }
  if (typeof obj === &quot;string&quot;) {
    return obj.replace(/\{([a-zA-Z0-9_-]+)\}/g, (match, key) =&gt; {
      const value = tokenMap[key]
      if (value === undefined) {
        console.warn(`⚠️ 警告: 令牌 &quot;${key}&quot; 未定义，保留原样`)
        return match
      }
      return resolveTokens(value, tokenMap, depth + 1)
    })
  }
  if (Array.isArray(obj))
    return obj.map((item) =&gt; resolveTokens(item, tokenMap, depth + 1))
  if (obj &amp;&amp; typeof obj === &quot;object&quot;) {
    const result = {}
    for (const [k, v] of Object.entries(obj)) {
      result[k] = resolveTokens(v, tokenMap, depth + 1)
    }
    return result
  }
  return obj
}

// 替换变量 ${var}：组件层引用语义层（支持透明后缀）
function replaceVariables(obj, colors) {
  if (typeof obj === &quot;string&quot;) {
    return obj.replace(
      /\$\{([a-zA-Z0-9_-]+)\}([0-9a-fA-F]{2})?/g,
      (match, key, alpha) =&gt; {
        const value = colors[key]
        if (value === undefined) {
          console.warn(`⚠️ 警告: 变量 &quot;${key}&quot; 未定义，保留原样`)
          return match
        }
        return value + (alpha || &quot;&quot;)
      },
    )
  }
  if (Array.isArray(obj))
    return obj.map((item) =&gt; replaceVariables(item, colors))
  if (obj &amp;&amp; typeof obj === &quot;object&quot;) {
    const result = {}
    for (const [k, v] of Object.entries(obj)) {
      result[k] = replaceVariables(v, colors)
    }
    return result
  }
  return obj
}

// 加载公共规则
const workbenchRaw = yaml.load(fs.readFileSync(PATHS.workbench, &quot;utf8&quot;))
const semanticRaw = yaml.load(fs.readFileSync(PATHS.semantic, &quot;utf8&quot;))

// 扫描并合并语言规则
let tokenColorsRaw = []
fs.readdirSync(PATHS.langDir)
  .filter((f) =&gt; f.endsWith(&quot;.yaml&quot;))
  .sort()
  .forEach((file) =&gt; {
    const rules = yaml.load(
      fs.readFileSync(path.join(PATHS.langDir, file), &quot;utf8&quot;),
    )
    if (rules?.tokenColors) {
      tokenColorsRaw = tokenColorsRaw.concat(rules.tokenColors)
    }
  })

// 读取 package.json 获取主题基础名
const pkg = JSON.parse(
  fs.readFileSync(path.join(ROOT_DIR, &quot;package.json&quot;), &quot;utf8&quot;),
)
const baseName = pkg.name.replace(/[^a-z0-9-]/gi, &quot;-&quot;).toLowerCase()

// 为每个语义层文件构建一个主题
const semanticFiles = fs
  .readdirSync(PATHS.semanticsDir)
  .filter((f) =&gt; f.endsWith(&quot;.yaml&quot;))

semanticFiles.forEach((semanticFile) =&gt; {
  const themeType = path.basename(semanticFile, &quot;.yaml&quot;) // 'dark' 或 'light'
  const semantics = yaml.load(
    fs.readFileSync(path.join(PATHS.semanticsDir, semanticFile), &quot;utf8&quot;),
  )

  // 1. 解析语义层的令牌引用 {token} -&gt; 最终色值
  const resolved = resolveTokens(semantics, primitives)

  // 2. 组件层替换变量 ${var}
  const uiColors = replaceVariables(workbenchRaw, resolved)
  const semanticColors = replaceVariables(semanticRaw, resolved)
  const tokenColors = replaceVariables(tokenColorsRaw, resolved)

  // 3. 组装主题对象
  const type = themeType.includes(&quot;light&quot;) ? &quot;light&quot; : &quot;dark&quot;
  const theme = {
    name: `${pkg.displayName || &quot;My Theme&quot;} ${themeType === &quot;dark&quot; ? &quot;Dark&quot; : &quot;Light&quot;}`,
    type: type,
    colors: uiColors,
    tokenColors: tokenColors,
    semanticTokenColors: semanticColors,
  }

  // 4. 写入输出文件
  const outputFile = path.join(PATHS.outputDir, `${baseName}-${themeType}.json`)
  if (!fs.existsSync(PATHS.outputDir)) {
    fs.mkdirSync(PATHS.outputDir, { recursive: true })
  }
  fs.writeFileSync(outputFile, JSON.stringify(theme, null, 2))
  console.log(`   ✅ 构建完成: ${outputFile}`)
})
</code></pre>

<h3 id="核心逻辑">核心逻辑</h3>

<ol>
<li>加载原始值。</li>
<li>扫描 <code>semantics/</code> 目录，每个语义层文件（<code>dark.yaml</code> / <code>light.yaml</code>）对应一个主题。</li>
<li>解析语义层的 <code>{token}</code> 引用，得到该变体的最终色值字典。</li>
<li>组件层用 <code>${var}</code> 替换为最终色值。</li>
<li>输出 <code>${baseName}-dark.json</code> 和 <code>${baseName}-light.json</code>。</li>
</ol>

<h3 id="新增主题的成本">新增主题的成本</h3>

<p>只需在 <code>semantics/</code> 目录下添加一个新的 YAML 语义层文件（如 <code>sepia.yaml</code>），构建脚本自动扫描生成对应主题。<strong>无需修改任何规则文件</strong>。</p>

<blockquote>
<p>🔮 <strong>预告</strong>：构建逻辑越来越复杂——解析令牌、替换变量、合并规则、扫描语义层。你可能会问：<strong>怎么证明它写得对？</strong> 当颜色出错、scope 匹配不上时，如何自动发现？这就是下一篇引入<strong>自动化测试与质量验证</strong>的原因。</p>
</blockquote>

<hr>

<h2 id="五-注册多个主题">📦 五、注册多个主题</h2>

<p>在 <code>package.json</code> 的 <code>contributes.themes</code> 中为每个主题添加一个条目，注意 <code>uiTheme</code> 字段的正确设置：</p>

<pre><code class="language-json">&quot;contributes&quot;: {
  &quot;themes&quot;: [
    {
      &quot;label&quot;: &quot;Moongate Dark&quot;,
      &quot;uiTheme&quot;: &quot;vs-dark&quot;,
      &quot;path&quot;: &quot;./themes/moongate-theme-dark.json&quot;
    },
    {
      &quot;label&quot;: &quot;Moongate Light&quot;,
      &quot;uiTheme&quot;: &quot;vs&quot;,
      &quot;path&quot;: &quot;./themes/moongate-theme-light.json&quot;
    }
  ]
}
</code></pre>

<ul>
<li><strong><code>uiTheme</code> 字段决定主题的基础色系</strong>：

<ul>
<li>深色主题：<code>&quot;vs-dark&quot;</code></li>
<li>浅色主题：<code>&quot;vs&quot;</code></li>
</ul></li>
<li><strong><code>label</code></strong> 将显示在 VS Code 命令面板的「颜色主题」列表中。</li>
</ul>

<blockquote>
<p>📌 <strong>一个容易踩的坑</strong>：如果浅色主题的 <code>uiTheme</code> 误设为 <code>&quot;vs-dark&quot;</code>，VS Code 会按照深色基础色系渲染控件（滚动条、输入框等），导致浅色主题出现深色控件——看起来很别扭。务必根据主题类型设置正确的 <code>uiTheme</code>。</p>
</blockquote>

<hr>

<h2 id="六-跨平台资产-颜色不只是主题">🌐 六、跨平台资产：颜色不只是主题</h2>

<p>DTCG 三层架构还有一个额外的巨大收益：<strong>语义层的颜色字典可以直接导出为跨平台资产</strong>。</p>

<p>构建脚本可以在生成主题 JSON 的同时，自动生成一个 CSS 变量文件：</p>

<pre><code class="language-css">/* themes/moongate-colors.css（自动生成） */
:root,
.light {
  --ui-primary: #0284c7;
  --ui-bg: #f9fafb;
  --ui-surface-raised: #ffffff;
  /* ... 浅色模式所有语义色 */
}

.dark {
  --ui-primary: #3b82f6;
  --ui-bg: #0f172a;
  --ui-surface-raised: #1a2538;
  /* ... 深色模式所有语义色 */
}
</code></pre>

<p>这意味着你的博客、组件库、文档站可以<strong>直接引用主题的视觉语言</strong>：</p>

<pre><code class="language-html">&lt;link rel=&quot;stylesheet&quot; href=&quot;/themes/moongate-colors.css&quot; /&gt;
</code></pre>

<pre><code class="language-css">body {
  background: var(--ui-bg);
  color: var(--ui-text);
}
</code></pre>

<p>切换深浅模式只需要在根元素上添加/移除 <code>.dark</code> class：</p>

<pre><code class="language-javascript">document.documentElement.classList.toggle(&quot;dark&quot;)
</code></pre>

<p>Moongate 正是这样做的——博客、设计系统文档与 VS Code 主题共享同一套颜色，实现「一个颜色体系，贯穿所有产品」。除了 CSS，还可以自动生成 SCSS 和 TypeScript 令牌（见构建体系）。</p>

<hr>

<h2 id="七-常见问题与陷阱">⚠️ 七、常见问题与陷阱</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>可能原因</th>
<th>解决方法</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>主题未出现在颜色主题列表中</strong></td>
<td><code>package.json</code> 中未正确注册，或 JSON 文件路径错误</td>
<td>检查 <code>contributes.themes</code> 条目，确保 <code>path</code> 指向正确的文件</td>
</tr>

<tr>
<td><strong>浅色主题显示为深色</strong></td>
<td><code>uiTheme</code> 字段误设为 <code>&quot;vs-dark&quot;</code></td>
<td>浅色主题应使用 <code>&quot;vs&quot;</code></td>
</tr>

<tr>
<td><strong>颜色变量未替换，仍显示为 <code>${var}</code></strong></td>
<td>变量名拼写错误，或语义层未定义该变量</td>
<td>检查变量名是否一致，确保所有语义变量在 <code>dark.yaml</code> 和 <code>light.yaml</code> 中都有定义</td>
</tr>

<tr>
<td><strong>深色/浅色主题视觉差异过大</strong></td>
<td>重力补偿不合理，明度调整幅度不均</td>
<td>遵循「色相不变、明度有规律降低、饱和度适度调整」的补偿规则</td>
</tr>

<tr>
<td><strong>新增语义变量后，某个主题报错</strong></td>
<td><code>dark.yaml</code> 和 <code>light.yaml</code> 变量名不一致</td>
<td>所有变体的语义层变量名必须完全一致</td>
</tr>

<tr>
<td><strong>构建脚本报错「找不到文件」</strong></td>
<td>缺少必要的 YAML 文件</td>
<td>确保 <code>src/core/primitives/</code>、<code>src/core/semantics/</code>、<code>src/languages/</code> 等目录存在，且包含所需文件</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="八-总结">🚀 八、总结</h2>

<p>通过引入 DTCG 三层架构，你将「多主题」升级为「设计系统」：</p>

<ul>
<li>✅ <strong>原始值层</strong>：颜色按色相-明度命名，成为可追溯的物理事实。</li>
<li>✅ <strong>语义层</strong>：角色与变体解耦，每个变体只定义「角色该是什么颜色」。</li>
<li>✅ <strong>组件层</strong>：规则文件完全复用，不写任何具体色值。</li>
<li>✅ <strong>重力补偿</strong>：同一语义角色在不同背景下视觉重量对等。</li>
<li>✅ <strong>海拔系统</strong>：UI 拥有物理深度，深浅模式层次一致。</li>
<li>✅ <strong>一键扩展</strong>：新增主题只需添加一个语义层文件。</li>
<li>✅ <strong>跨平台资产</strong>：语义层直接导出 CSS 变量，一套颜色贯穿所有产品。</li>
</ul>

<p>但你可能已经注意到：本篇的构建脚本仍然比较简单——它只能做变量替换，<strong>还不能验证颜色是否符合对比度标准、是否引用了未定义的变量、是否产生了架构污染</strong>。而且随着语言数量增加（当前 Moongate 支持 15 种语言），「语言规则写了对不上」的问题也会浮现。</p>

<p>下一篇将解决这些问题——<strong>如何让构建脚本自身成为一套可测试、可验证的工程体系</strong>。</p>

<p><a href="./create-vscode-theme-build-system"><strong>构建体系：可测试、可验证的工程实践</strong></a></p>
]]></content:encoded>
      <description><![CDATA[用量业界标准的 DTCG 设计令牌标准管理颜色，通过语义层与重力补偿构建深色/浅色双变体，让「同一语义角色在不同背景下视觉重量对等」从理念变为可执行的工程架构。]]></description>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[主题工程化：从单体 JSON 到模块化 YAML]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-engineering</link>
      <guid isPermaLink="true">https://moongate.top/docs/create-vscode-theme-engineering</guid>
      <pubDate>Thu, 06 Aug 2026 02:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>在<a href="./create-vscode-theme-basics">VS Code 主题</a>中，你已经学会了如何手写一个可发布的 VS Code 主题。但随着主题功能越来越丰富，你可能遇到了以下痛点：</p>

<ul>
<li>一个 JSON 文件动辄上千行，修改一个颜色需要全局搜索，容易误改。</li>
<li>想为不同语言定制高亮，却要在同一个 <code>tokenColors</code> 数组里堆砌规则，难以维护。</li>
<li>想尝试浅色版本，不得不复制整个文件，然后手动修改几百个颜色值。</li>
</ul>

<p>是时候引入工程化了！本篇将带你<strong>将一个单体的 JSON 主题重构为模块化、可自动构建的工程化项目</strong>：用 YAML 拆分源文件，用构建脚本自动合并与变量替换。</p>

<blockquote>
<p>💡 <strong>本篇的定位</strong>：本文采用的「单一颜色变量文件 + 构建脚本」方案是工程化的第一步。在<a href="./create-vscode-theme-design-system">设计系统</a>中，这个方案会进一步升级为 DTCG 三层架构（原始值 → 语义层 → 组件层）。建议按顺序阅读，理解每一步的动机。</p>
</blockquote>

<hr>

<h2 id="准备工作">📦 准备工作</h2>

<p>首先确保你已经安装了 Node.js 和 npm（或 pnpm）。然后安装构建依赖：</p>

<pre><code class="language-bash">pnpm add -D js-yaml
# 或
npm install --save-dev js-yaml
</code></pre>

<blockquote>
<p>⚠️ <strong>注意</strong>：<code>js-yaml</code> 必须安装在 <code>devDependencies</code> 中，因为它只是构建工具，不应作为生产依赖随主题发布。</p>
</blockquote>

<hr>

<h2 id="设计目录结构">📁 设计目录结构</h2>

<p>我们将源码放在 <code>src/</code> 目录下，构建脚本放在 <code>scripts/</code>，最终生成的 JSON 放在 <code>themes/</code>：</p>

<pre><code class="language-text">your-theme/
├── src/
│   ├── core/
│   │   └── colors.yaml          # 颜色变量（主色、背景、文本等）
│   ├── languages/                # 各语言的语法规则
│   │   ├── base.yaml             # 跨语言通用规则
│   │   ├── python.yaml
│   │   ├── go.yaml
│   │   └── ... (其他语言)
│   ├── workbench.yaml             # UI 颜色（colors 对象）
│   └── semantic.yaml              # 语义高亮（semanticTokenColors）
├── scripts/
│   └── build.js                   # 构建脚本
├── themes/
│   └── your-theme.json            # 构建生成的最终文件
├── package.json
└── .vscodeignore
</code></pre>

<blockquote>
<p>📌 <strong>注</strong>：上图中的 <code>languages/</code> 目录中并没有 <code>javascript.yaml</code>、<code>typescript.yaml</code>——因为 JS/TS 的语法规则完全被 <code>base.yaml</code> 的通用规则覆盖，无需单独文件。这也是「通用规则 + 语言独有规则」架构的核心思路：<strong>只在语言文件里放真正独有的规则</strong>。</p>
</blockquote>

<hr>

<h2 id="第一步-提取颜色变量">🎨 第一步：提取颜色变量</h2>

<p>打开你原有的主题 JSON，找出所有 <code>colors</code> 对象中的颜色值以及 <code>tokenColors</code> 中反复出现的颜色，将它们定义为变量。创建 <code>src/core/colors.yaml</code>：</p>

<pre><code class="language-yaml"># 核心颜色变量
primary: &quot;#3b82f6&quot; # 主蓝
success: &quot;#34d399&quot; # 成功绿
warning: &quot;#fbbf24&quot; # 警告黄
error: &quot;#f87171&quot; # 错误红
highlight: &quot;#7dd3fc&quot; # 发光蓝

bg: &quot;#0f172a&quot; # 背景
bgMuted: &quot;#1e293b&quot; # 次级背景
text: &quot;#e2e8f0&quot; # 前景色
textMuted: &quot;#94a3b8&quot; # 辅助文字
border: &quot;#2d3748&quot; # 边框


# ... 其他变量
</code></pre>

<h3 id="重要规则">⚠️ 重要规则</h3>

<ul>
<li>变量名使用驼峰或小写连字符，并且<strong>只使用字母、数字和下划线</strong>。</li>
<li><strong>变量中不要包含透明度</strong>（如 <code>#3b82f620</code> 中的 <code>20</code>），透明度应通过后缀 <code>${primary}20</code> 在引用时添加，构建脚本会自动拼接。</li>
<li>确保 <code>colors.yaml</code> 覆盖了所有将在其他文件中引用的变量，否则构建时会警告并保留原样。</li>
</ul>

<hr>

<h2 id="第二步-拆分语法规则">✂️ 第二步：拆分语法规则</h2>

<p>将 <code>tokenColors</code> 数组按语言拆分为多个 YAML 文件。以 <code>src/languages/base.yaml</code> 为例，存放所有语言共用的规则：</p>

<pre><code class="language-yaml"># 通用规则（base.yaml）
tokenColors:
  - name: Comment
    scope: [&quot;comment&quot;, &quot;punctuation.definition.comment&quot;]
    settings:
      fontStyle: &quot;italic&quot;
      foreground: &quot;${comment}&quot;

  - name: Keyword
    scope: [&quot;keyword&quot;, &quot;storage.type&quot;, &quot;storage.modifier&quot;]
    settings:
      foreground: &quot;${primary}&quot;
      fontStyle: &quot;bold&quot;

  - name: String
    scope: [&quot;string&quot;, &quot;string.quoted.single&quot;, &quot;string.quoted.double&quot;]
    settings:
      foreground: &quot;${success}&quot;
  # ... 其他通用规则
</code></pre>

<h3 id="核心原则">核心原则</h3>

<p><code>base.yaml</code> 已有的通用规则（关键字、字符串、注释、操作符、变量等），语言文件<strong>不再重复定义</strong>。语言文件只放真正属于该语言的独有规则。</p>

<p>以 <code>src/languages/go.yaml</code> 为例，只包含 Go 的特有语法元素：</p>

<pre><code class="language-yaml"># Go 专用规则（go.yaml）
tokenColors:
  - name: Go Package Clause
    scope: [&quot;source.go keyword.package.go&quot;]
    settings:
      foreground: &quot;${primary}&quot;
      fontStyle: &quot;bold&quot;

  - name: Go Struct
    scope: [&quot;keyword.struct.go&quot;]
    settings:
      foreground: &quot;${warning}&quot;
      fontStyle: &quot;bold&quot;

  - name: Go Error Variable
    scope: [&quot;variable.other.object.err.go&quot;]
    settings:
      foreground: &quot;${error}&quot;
      fontStyle: &quot;italic&quot;
  # ... 其他 Go 独有规则
</code></pre>

<p>这种「通用 + 独有」的架构让每个文件的职责都非常清晰：</p>

<ul>
<li>修改通用配色 → 只改 <code>base.yaml</code> 一处，全局生效。</li>
<li>为 Go 添加独有规则 → 只动 <code>go.yaml</code>，不影响其他语言。</li>
<li>想了解某语言的完整配色 → 看 <code>base.yaml</code> + 对应语言文件即可。</li>
</ul>

<hr>

<h2 id="第三步-拆分-ui-颜色和语义规则">🧩 第三步：拆分 UI 颜色和语义规则</h2>

<p>将 <code>colors</code> 对象移到 <code>src/workbench.yaml</code>，并将所有颜色值替换为变量引用：</p>

<pre><code class="language-yaml"># 编辑器 UI 颜色（workbench.yaml）
editor.background: &quot;${bg}&quot;
editor.foreground: &quot;${text}&quot;
titleBar.activeBackground: &quot;${bg}&quot;
titleBar.activeForeground: &quot;${text}&quot;
statusBar.background: &quot;${bg}&quot;
statusBar.foreground: &quot;${textMuted}&quot;
# ... 所有 UI 键
</code></pre>

<p>将 <code>semanticTokenColors</code> 移到 <code>src/semantic.yaml</code>，同样使用变量：</p>

<pre><code class="language-yaml"># 语义高亮规则（semantic.yaml）
variable: &quot;${variable}&quot;
function: &quot;${function}&quot;
class: &quot;${warning}&quot;
&quot;*.decorator&quot;:
  foreground: &quot;${purple}&quot;
  fontStyle: &quot;italic&quot;
# ... 其他语义规则
</code></pre>

<h3 id="注意">🔍 注意</h3>

<p>在 YAML 中，键名如果包含特殊字符（如 <code>*.decorator</code>）必须用双引号括起来，否则 YAML 解析会失败。</p>

<hr>

<h2 id="第四步-编写构建脚本">🔨 第四步：编写构建脚本</h2>

<p>创建 <code>scripts/build.js</code>，使用 <strong>ESM（ES Module）</strong> 语法。它的任务是：</p>

<ol>
<li>加载 <code>colors.yaml</code> 获得变量字典。</li>
<li>加载 <code>workbench.yaml</code>、<code>semantic.yaml</code> 以及 <code>languages/</code> 下的所有语言规则。</li>
<li>递归替换所有 <code>${变量名}</code> 为实际颜色值（支持透明度后缀）。</li>
<li>合并 <code>tokenColors</code>（<code>base.yaml</code> 先合并、语言规则后合并，后面的规则拥有更高优先级）。</li>
<li>输出最终的 JSON 到 <code>themes/</code>。</li>
</ol>

<pre><code class="language-javascript">// scripts/build.js
import fs from &quot;node:fs&quot;
import path from &quot;node:path&quot;
import yaml from &quot;js-yaml&quot;
import { fileURLToPath } from &quot;node:url&quot;

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(__dirname, &quot;..&quot;)

// 路径配置（根据你的项目结构调整）
const PATHS = {
  colors: path.join(ROOT_DIR, &quot;src&quot;, &quot;core&quot;, &quot;colors.yaml&quot;),
  workbench: path.join(ROOT_DIR, &quot;src&quot;, &quot;workbench.yaml&quot;),
  semantic: path.join(ROOT_DIR, &quot;src&quot;, &quot;semantic.yaml&quot;),
  langDir: path.join(ROOT_DIR, &quot;src&quot;, &quot;languages&quot;),
  outputDir: path.join(ROOT_DIR, &quot;themes&quot;),
}

// 加载颜色变量
const colors = yaml.load(fs.readFileSync(PATHS.colors, &quot;utf8&quot;))

// 递归替换 `${var}`，支持两位十六进制透明度后缀（如 ${primary}20）
function replaceVariables(obj) {
  if (typeof obj === &quot;string&quot;) {
    return obj.replace(
      /\$\{([a-zA-Z0-9_-]+)\}([0-9a-fA-F]{2})?/g,
      (match, key, alpha) =&gt; {
        const value = colors[key]
        if (value === undefined) {
          console.warn(`⚠️ 警告: 变量 &quot;${key}&quot; 未定义，保留原样`)
          return match
        }
        return value + (alpha || &quot;&quot;)
      },
    )
  }
  if (Array.isArray(obj)) {
    return obj.map(replaceVariables)
  }
  if (obj &amp;&amp; typeof obj === &quot;object&quot;) {
    const result = {}
    for (const [k, v] of Object.entries(obj)) {
      result[k] = replaceVariables(v)
    }
    return result
  }
  return obj
}

// 读取并解析 YAML
function loadYaml(filePath, description) {
  try {
    return yaml.load(fs.readFileSync(filePath, &quot;utf8&quot;))
  } catch (err) {
    console.error(`❌ 解析 ${description} 失败:`, err.message)
    return null
  }
}

// 加载 UI 颜色与语义规则
const workbench = replaceVariables(loadYaml(PATHS.workbench, &quot;workbench.yaml&quot;))
const semantic = replaceVariables(loadYaml(PATHS.semantic, &quot;semantic.yaml&quot;))

// 自动扫描 languages/ 目录下所有 YAML 文件，按文件名排序合并
// base.yaml 会被优先加载（作为通用规则），
// 语言专属规则随后合并（覆盖通用规则中相同 scope 的规则）
let tokenColors = []

if (fs.existsSync(PATHS.langDir)) {
  const langFiles = fs
    .readdirSync(PATHS.langDir)
    .filter((f) =&gt; f.endsWith(&quot;.yaml&quot;))
    .sort() // base.yaml 按字母序排在最前

  for (const file of langFiles) {
    const rules = loadYaml(path.join(PATHS.langDir, file), `语言规则 ${file}`)
    if (rules?.tokenColors) {
      tokenColors = tokenColors.concat(rules.tokenColors)
      console.log(`   ✅ 已加载: ${file}`)
    }
  }
}

// 替换 tokenColors 中的变量
const processedTokenColors = replaceVariables(tokenColors)

// 确保输出目录存在
if (!fs.existsSync(PATHS.outputDir)) {
  fs.mkdirSync(PATHS.outputDir, { recursive: true })
}

// 构建最终主题对象
const theme = {
  name: &quot;Your Theme Name&quot;,
  type: &quot;dark&quot;,
  colors: workbench,
  tokenColors: processedTokenColors,
  semanticTokenColors: semantic,
}

// 写入文件
const outputFile = path.join(PATHS.outputDir, &quot;your-theme.json&quot;)
fs.writeFileSync(outputFile, JSON.stringify(theme, null, 2))
console.log(&quot;✅ 主题构建完成！&quot;)
</code></pre>

<h3 id="注意事项">⚠️ 注意事项</h3>

<ul>
<li><strong>自动扫描而非手动排列</strong>：与过去手动维护 <code>order</code> 数组不同，这里直接扫描 <code>languages/</code> 目录并按文件名排序。<code>base.yaml</code> 按字母序天然排在最前，其余语言文件按字母序排在其后——后合并的规则覆盖前面相同 scope 的规则，语言专属规则天然拥有更高优先级。新增语言时只需添加 YAML 文件，无需修改构建脚本。</li>
<li>如果颜色变量未定义，脚本会给出警告，生成的 JSON 中会保留 <code>${var}</code> 占位符，导致主题无效。务必确保所有变量均已定义。</li>
<li><strong>透明度后缀格式</strong>：透明度后缀使用两位十六进制数（00–FF），其中 <code>20</code> 对应约 12.5% 透明度，<code>80</code> 对应 50%，<code>FF</code> 对应完全不透明。这种表示法直接对应 CSS 的 <code>#RRGGBBAA</code> 格式，便于构建脚本直接拼接。</li>
<li>变量名仅使用字母、数字和下划线（如 <code>primary</code>、<code>primaryColor</code>），避免使用连字符或其他符号——因为正则表达式 <code>\$\{([a-zA-Z0-9_-]+)\}</code> 只匹配这些字符。</li>
</ul>

<hr>

<h2 id="第五步-集成到-package-json">⚙️ 第五步：集成到 package.json</h2>

<p>在 <code>package.json</code> 的 <code>scripts</code> 中添加构建命令，并设置 <code>prepublishOnly</code> 自动构建：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;node scripts/build.js&quot;,
    &quot;prepublishOnly&quot;: &quot;npm run build&quot;
  }
}
</code></pre>

<p>🔍 检查：确保 <code>js-yaml</code> 在 <code>devDependencies</code> 中，而不是 <code>dependencies</code>。因为它是构建工具，不应随主题发布。</p>

<hr>

<h2 id="开发体验优化-实时预览与自动构建">✨ 开发体验优化：实时预览与自动构建</h2>

<p>手动运行 <code>npm run build</code> 每次修改后都很繁琐。我们可以添加一个 <strong>watch 模式</strong>，让构建脚本在源码文件变化时自动执行，实现「修改即预览」的高效工作流。</p>

<h3 id="1-安装-nodemon">1. 安装 nodemon</h3>

<p><code>nodemon</code> 是一个常用的工具，可以监视文件变化并自动重启命令。将它安装为开发依赖：</p>

<pre><code class="language-bash">pnpm add -D nodemon
# 或
npm install --save-dev nodemon
</code></pre>

<h3 id="2-添加-watch-脚本">2. 添加 watch 脚本</h3>

<p>在 <code>package.json</code> 的 <code>scripts</code> 中添加以下两个命令：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;node scripts/build.js&quot;,
    &quot;watch&quot;: &quot;nodemon --watch src -e yaml --exec \&quot;npm run build\&quot;&quot;,
    &quot;dev&quot;: &quot;npm run watch&quot;,
    &quot;prepublishOnly&quot;: &quot;npm run build&quot;
  }
}
</code></pre>

<ul>
<li><code>--watch src</code>：监视 <code>src</code> 目录下的所有文件变化。</li>
<li><code>-e yaml</code>：只监视扩展名为 <code>.yaml</code> 的文件。</li>
<li><code>--exec &quot;npm run build&quot;</code>：文件变化时执行构建命令。</li>
</ul>

<p><code>dev</code> 脚本是 <code>watch</code> 的别名，方便记忆。</p>

<h3 id="3-使用-watch-模式">3. 使用 watch 模式</h3>

<p>在开发过程中，打开终端运行：</p>

<pre><code class="language-bash">npm run watch
# 或
npm run dev
</code></pre>

<p>终端会保持运行状态，每当你在 <code>src/</code> 下修改并保存任何 YAML 文件时，构建脚本会自动执行，重新生成 <code>themes/</code> 下的 JSON 文件。</p>

<p>配合 VS Code 的调试功能，你只需按 <code>F5</code> 启动扩展开发宿主，然后保持 watch 运行。修改源码后，在开发宿主中执行 <code>Developer: Reload Window</code> 即可立即看到效果，无需手动重新构建。</p>

<h3 id="4-注意事项">4. 注意事项</h3>

<ul>
<li><code>nodemon</code> 只是开发时的辅助工具，不需要随主题发布，因此务必安装在 <code>devDependencies</code> 中。</li>
<li>如果你的项目结构复杂，可以自定义 <code>--watch</code> 参数监视更多目录。</li>
<li>如果不想安装额外依赖，也可以使用 Node.js 自带的 <code>fs.watch</code> 编写简单的监视脚本，但 <code>nodemon</code> 更成熟易用。</li>
</ul>

<hr>

<h2 id="第六步-更新-vscodeignore">📦 第六步：更新 .vscodeignore</h2>

<p>确保发布时只包含最终产物，不包含源码和依赖。一个典型的 <code>.vscodeignore</code> 内容如下：</p>

<pre><code class="language-bash">.vscode/**
.gitignore
vsc-extension-quickstart.md
node_modules
pnpm-lock.yaml
src/**
scripts/**
!themes/*.json
</code></pre>

<p>💡 说明：<code>!themes/*.json</code> 表示保留 <code>themes</code> 目录下的所有 JSON 文件，这些是构建产物。</p>

<hr>

<h2 id="第七步-测试构建">✅ 第七步：测试构建</h2>

<p>运行以下命令，检查生成的 JSON 是否与原文件一致：</p>

<pre><code class="language-bash">npm run build
</code></pre>

<p>然后用 diff 工具对比新生成的 <code>themes/your-theme.json</code> 与原始 JSON，确保没有意外差异。如果有差异，请检查：</p>

<ul>
<li>变量定义是否完整。</li>
<li>透明度后缀是否正确。</li>
<li>语言规则合并顺序是否符合预期。</li>
</ul>

<hr>

<h2 id="第八步-享受工程化带来的便利">🚀 第八步：享受工程化带来的便利</h2>

<p>现在你的主题源码已经模块化，维护变得轻而易举：</p>

<ul>
<li>想修改主色？只需改 <code>colors.yaml</code> 一处。</li>
<li>想为 Python 添加新规则？直接在 <code>python.yaml</code> 中增加条目。</li>
<li>想创建浅色版本？新建 <code>colors-light.yaml</code>，并调整构建脚本输出两个主题。</li>
</ul>

<hr>

<h2 id="常见问题排查">🔍 常见问题排查</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>可能原因</th>
<th>解决方法</th>
</tr>
</thead>

<tbody>
<tr>
<td>构建后颜色值仍为 <code>${var}</code></td>
<td>变量未在 <code>colors.yaml</code> 中定义</td>
<td>检查变量名拼写，确保变量已定义</td>
</tr>

<tr>
<td>透明度不正确</td>
<td>变量本身已包含 alpha，或透明度后缀格式错误</td>
<td>变量中不应包含透明度，使用 <code>${var}20</code> 形式</td>
</tr>

<tr>
<td>某语言高亮缺失</td>
<td>语言规则文件不存在，或规则不在 <code>base.yaml</code> 通用范围内</td>
<td>添加对应语言文件；注意语言文件只放独有规则，通用规则放 <code>base.yaml</code></td>
</tr>

<tr>
<td>规则被意外覆盖</td>
<td>合并顺序不符合预期</td>
<td>文件名排序控制顺序：<code>base.yaml</code> 排最前，语言文件按字母序在后，后合并的规则覆盖前面的相同 scope 规则</td>
</tr>

<tr>
<td><code>vsce package</code> 报错「missing dependencies」</td>
<td><code>js-yaml</code> 被放在了 <code>dependencies</code> 中</td>
<td>将其移到 <code>devDependencies</code></td>
</tr>

<tr>
<td>透明度后缀格式不支持三位（如 <code>200</code>）</td>
<td>脚本仅支持两位十六进制透明度后缀（如 <code>20</code>）</td>
<td>确保透明度后缀始终为两位十六进制，并在引用时使用 <code>${var}20</code> 形式</td>
</tr>

<tr>
<td>变量名包含连字符或非单词字符导致无法替换</td>
<td>变量命名不规范，正则表达式 <code>\$\{(\w+)\}</code> 只能匹配字母、数字和下划线</td>
<td>变量名仅使用字母、数字和下划线（如 <code>primary</code>、<code>primaryColor</code>），避免使用连字符或其他符号</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="总结">📝 总结</h2>

<p>通过工程化重构，你从一个难以维护的 JSON 单体进化到了一个清晰、可扩展的模块化项目。你已经拥有了：</p>

<ul>
<li>✅ 模块化的 YAML 源文件（颜色、语言规则、UI、语义高亮分离）</li>
<li>✅ 自动合并与变量替换的构建脚本</li>
<li>✅ watch 模式实时预览开发</li>
<li>✅ 发布时自动构建的正确配置</li>
</ul>

<p>但你可能已经注意到，上一篇文章的方案还有一些问题需要解决：</p>

<ul>
<li><code>colors.yaml</code> 是一个<strong>扁平的变量池</strong>，颜色之间的层级关系（哪些是原始色、哪些是语义角色）完全靠命名约定，没有结构性的约束。</li>
<li>深浅两套主题需要<strong>两套颜色变量文件</strong>，而「同一角色在深色和浅色下应该保持色相一致、明度不同」这件事完全靠手动维护。</li>
<li>构建脚本只能做变量替换，还<strong>不能自动校验</strong>颜色是否符合对比度标准、是否引用了不存在的变量。</li>
</ul>

<p>这些问题，正是我们下一篇要解决的——<strong>设计系统：DTCG 三层架构与昼夜双变体</strong>。</p>

<p><a href="./create-vscode-theme-design-system"><strong>设计系统：DTCG 三层架构与昼夜双变体</strong></a></p>
]]></content:encoded>
      <description><![CDATA[将单体 JSON 重构为模块化 YAML 项目，用构建脚本实现变量替换与自动生成。让颜色变量可复用、语言规则可维护，为设计系统升级打下坚实基础。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[VS Code 主题：从手写 JSON 到可发布]]></title>
      <link>https://moongate.top/docs/create-vscode-theme-basics</link>
      <guid isPermaLink="true">https://moongate.top/docs/create-vscode-theme-basics</guid>
      <pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>如果你有编程基础（熟悉 JavaScript、JSON、命令行），想把一套配色方案变成 VS Code 主题，但完全不知道从何下手——这篇文章正是为你准备的。</p>

<p>Moongate 主题最初就是从个人博客的配色衍生而来。在这篇文章中，我们不使用任何脚手架，而是<strong>从一个最小的 JSON 文件开始</strong>，逐步理解主题的运作机制。等机制清楚了，你自然会发现哪些环节需要工程化——那就是整个系列接下来要做的事。</p>

<hr>

<h2 id="一-vs-code-主题的本质">一、VS Code 主题的本质</h2>

<p>一个主题说到底就是一份 JSON 文件，它告诉 VS Code 两件事：</p>

<ul>
<li><strong>界面长什么样</strong>：标题栏、状态栏、侧边栏、编辑器背景……这些 UI 区域的颜色。</li>
<li><strong>代码怎么着色</strong>：不同的语法元素（关键字、字符串、注释、函数……）分别用什么颜色和样式。</li>
</ul>

<p>理解文件结构本身并不难，真正难的是<strong>知道该配置哪些键名、每个键名是什么意思</strong>。所以这篇文章的核心目标是：带你亲手创建一个最小可用的主题，然后告诉你如何用 VS Code 自带的工具，找到任何你想调整的颜色所对应的正确键名。</p>

<hr>

<h2 id="二-自定义主题的最小路径">二、自定义主题的最小路径</h2>

<h3 id="1-创建项目结构">1. 创建项目结构</h3>

<p>首先准备一个空目录，并创建 <code>themes/</code> 文件夹：</p>

<pre><code class="language-bash">mkdir my-theme
cd my-theme
mkdir themes
</code></pre>

<p>虽然 VS Code 的扩展项目通常还需要 <code>package.json</code>、<code>README.md</code> 等文件，但为了让读者先专注理解结构，这里从主题 JSON 本身开始。</p>

<h3 id="2-编写最小主题-json">2. 编写最小主题 JSON</h3>

<p>在 <code>themes/</code> 下创建一个 JSON 文件，例如 <code>my-theme.json</code>：</p>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;My Theme&quot;,
  &quot;type&quot;: &quot;dark&quot;,
  &quot;colors&quot;: {
    &quot;editor.background&quot;: &quot;#0f172a&quot;,
    &quot;editor.foreground&quot;: &quot;#e2e8f0&quot;
  },
  &quot;tokenColors&quot;: [
    {
      &quot;name&quot;: &quot;Comment&quot;,
      &quot;scope&quot;: [&quot;comment&quot;, &quot;punctuation.definition.comment&quot;],
      &quot;settings&quot;: {
        &quot;fontStyle&quot;: &quot;italic&quot;,
        &quot;foreground&quot;: &quot;#a5b4cb&quot;
      }
    },
    {
      &quot;name&quot;: &quot;Keyword&quot;,
      &quot;scope&quot;: [&quot;keyword&quot;, &quot;storage.type&quot;, &quot;storage.modifier&quot;],
      &quot;settings&quot;: {
        &quot;foreground&quot;: &quot;#3b82f6&quot;,
        &quot;fontStyle&quot;: &quot;bold&quot;
      }
    },
    {
      &quot;name&quot;: &quot;String&quot;,
      &quot;scope&quot;: [&quot;string&quot;, &quot;string.quoted.single&quot;, &quot;string.quoted.double&quot;],
      &quot;settings&quot;: {
        &quot;foreground&quot;: &quot;#34d399&quot;
      }
    }
  ]
}
</code></pre>

<p>这个文件虽然很小，但它已经包含了主题的全部核心结构。</p>

<hr>

<h2 id="三-核心概念-colors-与-tokencolors">三、核心概念：<code>colors</code> 与 <code>tokenColors</code></h2>

<h3 id="colors-编辑器界面"><code>colors</code>：编辑器界面</h3>

<p><code>colors</code> 对象定义编辑器 <strong>UI 的配色</strong>——背景、标题栏、状态栏、侧边栏、选区高亮等。它是一个扁平的对象，键名是 VS Code 预定义的接口，例如：</p>

<table>
<thead>
<tr>
<th>键名</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>editor.background</code></td>
<td>编辑器背景</td>
</tr>

<tr>
<td><code>editor.foreground</code></td>
<td>编辑器默认前景色</td>
</tr>

<tr>
<td><code>titleBar.activeBackground</code></td>
<td>标题栏背景</td>
</tr>

<tr>
<td><code>statusBar.background</code></td>
<td>状态栏背景</td>
</tr>

<tr>
<td><code>sideBar.background</code></td>
<td>侧边栏背景</td>
</tr>
</tbody>
</table>

<blockquote>
<p>⚠️ <strong><code>type</code> 字段</strong>：顶级 <code>type</code> 字段决定主题是深色还是浅色，取值为 <code>&quot;dark&quot;</code> 或 <code>&quot;light&quot;</code>。它会影响 VS Code 默认控件的渲染方式（如滚动条、输入框的自适应）。</p>
</blockquote>

<h3 id="tokencolors-代码语法高亮"><code>tokenColors</code>：代码语法高亮</h3>

<p><code>tokenColors</code> 是一个<strong>规则数组</strong>，每条规则由 <code>scope</code> 和 <code>settings</code> 组成：</p>

<ul>
<li><code>scope</code>：匹配的 TextMate 作用域（scope），可以是字符串或字符串数组。</li>
<li><code>settings</code>：该作用域应用的颜色与样式（<code>foreground</code>、<code>fontStyle</code>、<code>background</code>）。</li>
</ul>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;Comment&quot;,
  &quot;scope&quot;: [&quot;comment&quot;, &quot;punctuation.definition.comment&quot;],
  &quot;settings&quot;: {
    &quot;fontStyle&quot;: &quot;italic&quot;,
    &quot;foreground&quot;: &quot;#a5b4cb&quot;
  }
}
</code></pre>

<h4 id="重要">⚠️ 重要</h4>

<p><code>tokenColors</code> 数组的<strong>顺序很重要</strong>——后面的规则会覆盖前面相同 <code>scope</code> 的规则。这也是为什么工程化之后我们会用一个独立的 <code>base.yaml</code> 管理通用规则，再在语言文件中叠加专属规则。</p>

<hr>

<h2 id="四-如何找到正确的键名">四、如何找到正确的键名？</h2>

<p>这是所有主题开发者最常遇到的问题：「我想把状态栏文字改成蓝色，该用什么键名？」</p>

<p>VS Code 提供了两个非常强大的内置工具，彻底解决了这个问题。</p>

<h3 id="1-ui-颜色-提取当前主题">1. UI 颜色：提取当前主题</h3>

<p>按下 <code>Ctrl+Shift+P</code>，运行命令 <strong><code>Developer: Generate Color Theme From Current Settings</code></strong>。</p>

<p>这会在输出面板中生成一个 JSON，包含<strong>当前主题所用到的所有 UI 颜色键名</strong>以及它们的值。你只需要：</p>

<ol>
<li>在输出中找到你关心的区域（比如 <code>statusBar.foreground</code>）。</li>
<li>复制这个键名，粘贴到你自己的 <code>colors</code> 对象中。</li>
<li>改成你自己的颜色值。</li>
</ol>

<p>这个命令生成的是「当前生效值」，即使某个键名你从未配置过（VS Code 在使用默认值），它也会出现在输出中——相当于一份完整的键名清单。</p>

<h3 id="2-语法-scope-inspect-工具">2. 语法 scope：Inspect 工具</h3>

<p>这是定位语法高亮问题的<strong>神器</strong>。打开任意代码文件，把光标放在你想着色的元素上，运行 <strong><code>Developer: Inspect Editor Tokens and Scopes</code></strong>。</p>

<p>弹出的窗口会显示：</p>

<ul>
<li><strong>该元素当前的 TextMate scope 链</strong>，从最具体到最通用。</li>
<li><strong>当前颜色来自哪条规则</strong>（<code>foreground</code> 来源），以及是哪个文件定义的。</li>
</ul>

<p>使用技巧：</p>

<ul>
<li><strong>选择最具体的 scope</strong> 来精确命中目标元素，避免误伤同一族元素。</li>
<li>例如注释既有 <code>comment.line</code> 也有 <code>comment.block</code>，如果你只想给行注释着色，就选 <code>comment.line</code>；如果想统一处理所有注释，就选 <code>comment</code>。</li>
<li>当你发现某个元素颜色「不对」时，Inspect 窗口会直接告诉你当前颜色来自哪个规则——这排查思路清晰得多。</li>
</ul>

<hr>

<h2 id="五-本地调试">五、本地调试</h2>

<p>在确认主题文件书写正确后，下一步就是把它加载进 VS Code 实时查看。</p>

<h3 id="1-创建-package-json">1. 创建 package.json</h3>

<p>要运行主题扩展，需要一个最小的 <code>package.json</code> 将它声明为扩展：</p>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;my-theme&quot;,
  &quot;displayName&quot;: &quot;My Theme&quot;,
  &quot;version&quot;: &quot;0.0.1&quot;,
  &quot;publisher&quot;: &quot;your-name&quot;,
  &quot;engines&quot;: {
    &quot;vscode&quot;: &quot;^1.109.0&quot;
  },
  &quot;categories&quot;: [&quot;Themes&quot;],
  &quot;contributes&quot;: {
    &quot;themes&quot;: [
      {
        &quot;label&quot;: &quot;My Theme&quot;,
        &quot;uiTheme&quot;: &quot;vs-dark&quot;,
        &quot;path&quot;: &quot;./themes/my-theme.json&quot;
      }
    ]
  }
}
</code></pre>

<p>关键字段：</p>

<ul>
<li><strong><code>contributes.themes</code></strong>：声明主题列表，每项包含 <code>label</code>（在颜色主题列表中显示的名称）、<code>uiTheme</code>（基础色系：<code>vs-dark</code> 深色 / <code>vs</code> 浅色）、<code>path</code>（主题 JSON 的相对路径）。</li>
<li><strong><code>publisher</code></strong>：你的发布者 ID。可以先填占位符，发布前注册后再回来修改。</li>
<li><strong><code>engines</code></strong>：最低 VS Code 版本要求。</li>
</ul>

<h3 id="2-f5-启动扩展开发主机">2. F5 启动扩展开发主机</h3>

<ol>
<li>在 VS Code 中打开项目文件夹。</li>
<li>按 <code>F5</code>，启动「扩展开发主机」窗口。</li>
<li>在开发主机中按 <code>Ctrl+K Ctrl+T</code>，选择你的主题。</li>
<li>打开测试代码文件，实时查看效果。</li>
</ol>

<p>修改主题 JSON 后，在开发主机中按 <code>Ctrl+R</code> 重新加载，修改立即生效。<strong>注意</strong>：修改 <code>package.json</code> 中的 <code>contributes.themes</code> 后，需要重启开发主机会话才能生效。</p>

<hr>

<h2 id="六-准备发布">六、准备发布</h2>

<h3 id="1-完善-package-json">1. 完善 package.json</h3>

<p>发布之前，需要补全关键字段：</p>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;my-theme&quot;,
  &quot;displayName&quot;: &quot;My Theme&quot;,
  &quot;description&quot;: &quot;简短介绍你的主题&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;publisher&quot;: &quot;你的发布者ID&quot;,
  &quot;engines&quot;: { &quot;vscode&quot;: &quot;^1.109.0&quot; },
  &quot;categories&quot;: [&quot;Themes&quot;],
  &quot;icon&quot;: &quot;images/icon.png&quot;,
  &quot;repository&quot;: {
    &quot;type&quot;: &quot;git&quot;,
    &quot;url&quot;: &quot;https://github.com/yourname/my-theme&quot;
  }
}
</code></pre>

<h3 id="2-准备截图">2. 准备截图</h3>

<p>在根目录创建 <code>images/</code> 文件夹，放入至少 3-5 张不同语言的代码截图（建议 1280×640），用于 README 和商店展示。</p>

<h3 id="3-编写-readme-md">3. 编写 README.md</h3>

<p>包含主题名称、预览截图、设计理念、安装方法、配色表（可选）等。</p>

<h3 id="4-添加-license">4. 添加 LICENSE</h3>

<p>建议使用 MIT 许可证，创建 <code>LICENSE</code> 文件。</p>

<h3 id="5-创建-vscodeignore">5. 创建 <code>.vscodeignore</code></h3>

<p>排除不需要打包的文件，<strong>但务必保留 <code>images/</code> 文件夹</strong>：</p>

<pre><code class="language-bash">.vscode/**
.gitignore
node_modules
</code></pre>

<hr>

<h2 id="七-发布前检查清单">七、发布前检查清单</h2>

<p>在运行 <code>vsce package</code> 之前，花两分钟核对以下事项，可以避免大部分常见的发布错误：</p>

<ul>
<li><strong><code>package.json</code> 信息</strong>：确保 <code>publisher</code>、<code>name</code>、<code>version</code> 字段正确无误（<code>publisher</code> 必须与你在市场注册的 ID 完全一致）。</li>
<li><strong>图标文件</strong>：确认 <code>icon</code> 路径指向一个 <strong>128×128 像素的 PNG 图片</strong>，且文件确实存在于该位置。</li>
<li><strong>预览图</strong>：检查 <code>README.md</code> 中是否包含了至少一张主题预览图（建议使用 <code>images/</code> 文件夹内的截图），没有预览图的主题很难吸引用户。</li>
<li><strong><code>.vscodeignore</code> 配置</strong>：确认已排除不必要的文件，但<strong>务必保留 <code>images/</code> 文件夹</strong>，否则截图无法随扩展一起发布。</li>
<li><strong>本地打包测试</strong>：运行 <code>vsce package</code> 命令，若能成功生成 <code>.vsix</code> 文件，说明配置基本正确。如果失败，仔细阅读错误提示——最常见的原因是 <code>icon</code> 路径错误或 <code>publisher</code> 未设置。</li>
</ul>

<hr>

<h2 id="八-打包与发布">八、打包与发布</h2>

<h3 id="1-安装发布工具">1. 安装发布工具</h3>

<pre><code class="language-bash">npm install -g @vscode/vsce
</code></pre>

<h3 id="2-打包测试">2. 打包测试</h3>

<pre><code class="language-bash">vsce package
</code></pre>

<p>如果成功，会生成 <code>.vsix</code> 文件。可以拖进 VS Code 手动安装测试。若失败，仔细阅读错误提示，常见原因是 <code>icon</code> 路径错误或 <code>.vscodeignore</code> 误排除了必要文件。</p>

<h3 id="3-获取-personal-access-token">3. 获取 Personal Access Token</h3>

<ul>
<li>登录 <a href="https://dev.azure.com" target="_blank">Azure DevOps</a>（用你注册市场的微软账号）。</li>
<li>右上角头像 → Personal access tokens → New Token。</li>
<li>名称随意，组织选 <strong>All accessible organizations</strong>，有效期建议 1 年。</li>
<li><strong>权限</strong>：只勾选 <strong>Marketplace → Manage</strong>。</li>
<li>创建后<strong>立即复制 Token</strong>（只显示一次）。</li>
</ul>

<h3 id="4-登录并发布">4. 登录并发布</h3>

<pre><code class="language-bash">vsce login 你的发布者ID
# 粘贴刚才的 Token（不会显示，直接回车）

vsce publish
</code></pre>

<p>几秒后主题就会上传。约 5-10 分钟即可在 VS Code 中搜索到。</p>

<h4 id="常见发布错误">常见发布错误</h4>

<ul>
<li><code>Token verification failed</code>：权限未正确设置或 Token 过期，重新生成。</li>
<li><code>Version already exists</code>：版本号重复，更新 <code>package.json</code> 中的 <code>version</code>。</li>
<li>网络问题：尝试更换网络或使用代理。</li>
</ul>

<h3 id="替代方案-手动上传-vsix-文件">🔁 替代方案：手动上传 .vsix 文件</h3>

<p>如果你在命令行方式中遇到困难，完全可以通过浏览器手动上传：</p>

<ol>
<li>首先确保你已经运行 <code>vsce package</code> 成功生成了 <code>.vsix</code> 文件。</li>
<li>访问 <a href="https://marketplace.visualstudio.com/manage" target="_blank">VS Code 市场管理页</a>，用你的微软账号登录。</li>
<li>在页面中点击你的发布者名称，进入发布者管理界面。</li>
<li>点击右上角的 <strong>Publish extension</strong> 按钮。</li>
<li>选择你的 <code>.vsix</code> 文件上传。</li>
<li>几分钟后主题就会出现在市场中。</li>
</ol>

<h4 id="优点">优点</h4>

<p>完全绕过命令行 token 验证，过程可视化。</p>

<hr>

<h2 id="九-发布后">九、发布后</h2>

<ul>
<li>查看市场页面：<code>https://marketplace.visualstudio.com/items?itemName=你的发布者ID.你的主题名</code></li>
<li>登录 <a href="https://marketplace.visualstudio.com/manage" target="_blank">市场管理后台</a> 查看报表（页面浏览、安装量、转化率）。</li>
<li>在 GitHub 仓库添加 README 徽章（如版本、下载量）。</li>
<li>收集反馈，准备后续更新。</li>
</ul>

<hr>

<h2 id="十-设计哲学-从-好看-到-好用">十、设计哲学：从「好看」到「好用」</h2>

<p>很多新手以为主题就是配几个好看的颜色。但真正优秀的主题，能让代码的结构自己「浮现」出来。这就是 <strong>「视觉远近法」</strong> 的理念：</p>

<ul>
<li><strong>操作符、标点应该退后</strong>（亮度低一些），不干扰阅读；</li>
<li><strong>函数名应该发光</strong>（亮度高一些），成为视觉锚点；</li>
<li><strong>只读变量应该用斜体</strong>暗示「不可变」；</li>
<li><strong>废弃代码应该加上删除线</strong>，一眼识别。</li>
</ul>

<p>这套原则在 Moongate 中落地为「语义分层」：前景（核心逻辑）、中景（普通代码）、背景（辅助信息）三个视觉层级。你可以在后续的系列文章中看到它如何演变为完整的设计体系。</p>

<hr>

<h2 id="为什么手写-json-不可持续">为什么手写 JSON 不可持续？</h2>

<p>现在你已经拥有一个可以手动编辑、可以发布的 VS Code 主题了。但当你认真用起来，很快就会遇到这些痛点：</p>

<ul>
<li>一个 JSON 文件动辄上千行，修改一个颜色需要全局搜索，容易误改。</li>
<li>想为不同语言定制高亮，却要在同一个 <code>tokenColors</code> 数组里堆砌规则，难以维护。</li>
<li>想尝试浅色版本，不得不复制整个文件，然后手动修改几百个颜色值。</li>
<li>一个不小心，就会用错 scope，某个语言的高亮「规则写了对不上」。</li>
</ul>

<p>这些问题不是你的失误，而是<strong>手写 JSON 这种工作方式的极限</strong>。这正是我们接下来的系列要解决的问题——从工程化到设计系统，让主题维护变得轻松而优雅。</p>

<p><a href="./create-vscode-theme-engineering"><strong>主题工程化：从单体 JSON 到模块化 YAML</strong></a></p>
]]></content:encoded>
      <description><![CDATA[不依赖脚手架，从零手写最小主题 JSON，理解 colors 与 tokenColors 的核心机制，掌握调试、打包与发布的完整流程，构建属于你的第一个 VS Code 主题。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Vue 3 Teleport 组件单元测试指南：5 个 jsdom 陷阱与顺手抓到的 2 个 Bug]]></title>
      <link>https://moongate.top/docs/vue-teleport-unit-testing-jsdom-pitfalls</link>
      <guid isPermaLink="true">https://moongate.top/docs/vue-teleport-unit-testing-jsdom-pitfalls</guid>
      <pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>给 Teleport 组件写测试，5 个 jsdom 陷阱 + 顺手抓到的 2 个隐藏 Bug——每个坑都附可复现的最小示例。</p>
</blockquote>

<h2 id="背景">背景</h2>

<p>Moongate Vue 是一个包含 25 个组件的 Vue 3 组件库，其中 <code>Modal</code>（模态框）、<code>Drawer</code>（抽屉）、<code>Message</code>（消息）、<code>Toast</code>（通知）等都使用了 <code>&lt;Teleport to=&quot;body&quot;&gt;</code> 将内容渲染到 <code>document.body</code>。</p>

<p>为了让测试环境尽量接近真实浏览器，我们选了 Vitest + jsdom 作为测试基础设施。原以为只是写几个断言的事，结果在 Teleport 组件上反复栽跟头——<strong>24 个测试失败，其中一大半都指向同一个诡异报错</strong>：</p>

<pre><code class="language-text">TypeError: Cannot read properties of null (reading 'insertBefore')
</code></pre>

<p>排查到最后发现，这不是巧合，而是 <strong>Teleport 机制 + jsdom 环境 + Vue 异步更新</strong> 三者在特定时序下的必然结果。下面按&rdquo;坑 → 表现 → 根因 → 解法&rdquo;拆解。</p>

<hr>

<h2 id="第一部分-teleport-测试的-5-个陷阱">第一部分：Teleport 测试的 5 个陷阱</h2>

<h3 id="陷阱-1-teleport-内容在-body-顶层-wrapper-查不到">陷阱 1：Teleport 内容在 body 顶层，wrapper 查不到</h3>

<p><strong>表现</strong>：</p>

<p><code>wrapper.find('.mg-modal-overlay')</code> 返回空，即使组件已经通过 <code>attachTo: document.body</code> 挂载。</p>

<p><strong>根因</strong>：Teleport 的目标元素是 <code>body</code>，其渲染内容作为 <code>body</code> 的<strong>直接子节点</strong>，<strong>不在 <code>wrapper.element</code> 子树内</strong>。即使能通过 <code>wrapper.vm</code> 访问到组件实例，Teleport 渲染的 <code>v-if</code> 内容也在 wrapper 管理的 DOM 树之外。</p>

<pre><code class="language-ts">// ❌ 错误：wrapper 找不到 Teleport 内容
const wrapper = mount(Modal, { props: { modelValue: true } })
expect(wrapper.find(&quot;.mg-modal-overlay&quot;).exists()).toBe(true)

// ✅ 正确：从 body 顶层查询
const wrapper = mount(Modal, {
  props: { modelValue: true },
  attachTo: document.body, // ① 确保挂载到 body
})
expect(document.body.querySelector(&quot;.mg-modal-overlay&quot;)).not.toBeNull()
</code></pre>

<p><strong>解法</strong>：</p>

<ol>
<li><code>attachTo: document.body</code> 确保组件渲染在 body 中</li>
<li>断言 Teleport 内容永远用 <code>document.body.querySelector</code>，而不是 <code>wrapper.find</code></li>
</ol>

<hr>

<h3 id="陷阱-2-依赖自动卸载-触发-insertbefore-on-null">陷阱 2：依赖自动卸载，触发 <code>insertBefore on null</code></h3>

<p><strong>表现</strong>：测试断言全通过，但 Vitest 结束后抛出一个异步错误：</p>

<pre><code class="language-bash">TypeError: Cannot read properties of null (reading 'insertBefore')
  at insert (runtime-dom.cjs.js:31)
  at processCommentNode ...
</code></pre>

<p><strong>根因</strong>：Vue 的响应式更新是<strong>异步批量 patch</strong> 的。当测试结束时 wrapper 被自动卸载（或 <code>document.body.innerHTML = ''</code> 被清空），但 Vue 内部 scheduler 队列里还挂着上一轮渲染的 DOM patch 任务。这个 patch 试图操作已经脱离 DOM 的旧节点 → 崩溃。</p>

<p>Teleport 组件尤其容易触发，因为它的插入点（body）是测试环境里<strong>最容易被清空</strong>的目标。</p>

<p><strong>解法</strong>：测试结束时显式卸载 wrapper，让 Teleport 有完整机会摘除自己：</p>

<pre><code class="language-ts">import { mount } from &quot;@vue/test-utils&quot;

// 跟踪所有 wrapper，统一显式卸载
const wrappers: ReturnType&lt;typeof mount&gt;[] = []
const mountDrawer = (options = {}) =&gt; {
  const wrapper = mount(Drawer, { attachTo: document.body, ...options })
  wrappers.push(wrapper)
  return wrapper
}

afterEach(async () =&gt; {
  while (wrappers.length &gt; 0) {
    const wrapper = wrappers.pop()!
    await wrapper.unmount() // 显式卸载，而非依赖自动清理
  }
})
</code></pre>

<hr>

<h3 id="陷阱-3-测试间-dom-污染">陷阱 3：测试间 DOM 污染</h3>

<p><strong>表现</strong>：第一个测试用例渲染了一个 Modal，第二个用例查询 <code>.mg-modal-overlay</code> 莫名找到了<strong>上一轮残留</strong>的元素；或者反过来，第二个用例找不到预期元素。</p>

<p><strong>根因</strong>：Teleport 把内容加到 <code>document.body</code>，但测试框架的自动清理不一定销毁它们。尤其当组件内部持有<strong>模块级缓存</strong>（比如我们的 <code>createOverlay</code> 共享容器 Map）时，残留引用会让 DOM 越积越多。</p>

<pre><code class="language-ts">// ❌ 无法清理 Teleport 残留
afterEach(() =&gt; {
  document.body.innerHTML = &quot;&quot; // 直接把 body 清空
})
</code></pre>

<p>直接清空 body 是<strong>有问题的</strong>，因为 Vue 内部仍引用着这些节点，下一个 tick 的 patch 会对已移除节点操作而爆炸（这正是陷阱 2 的错误来源）。</p>

<p><strong>解法</strong>：先 flush 再清空：</p>

<pre><code class="language-ts">afterEach(async () =&gt; {
  await flushPromises() // ① 等待 Vue 异步作业（Teleport 移除、nextTick patch）完成
  document.body.innerHTML = &quot;&quot; // ② 再清空
})
</code></pre>

<blockquote>
<p>⚠️ <strong>注意</strong>：如果测试中使用了 <code>vi.useFakeTimers()</code>，<code>flushPromises()</code> 只能清空微任务队列，<strong>清不掉宏任务</strong>（如 <code>setTimeout</code> 触发的 DOM 操作）。此时应还原真实定时器，再清空 body：</p>

<pre><code class="language-ts">afterEach(async () =&gt; {
  await flushPromises()
  // 若用了假定时器，直接还原真实定时器（会自动清空假定时器队列），比跑完所有宏任务更安全
  if (vi.isFakeTimers()) {
    vi.useRealTimers()
  }
  document.body.innerHTML = &quot;&quot;
})
</code></pre>

<p>⚠️ <strong>避免 <code>vi.runAllTimersAsync()</code> 的陷阱</strong>：它会把所有宏任务（包括 <code>setInterval</code>、<code>requestAnimationFrame</code> 这类<strong>永久性定时器</strong>）循环跑完。如果被测组件内部有无限轮询或动画循环，这个调用会导致测试<strong>卡死/超时</strong>。<strong>还原真实定时器是更稳妥的清理方式</strong>。</p>
</blockquote>

<hr>

<h3 id="陷阱-4-清理顺序错误-先清空-body-导致-vue-找不到父节点">陷阱 4：清理顺序错误，先清空 body 导致 Vue 找不到父节点</h3>

<p><strong>表现</strong>：诡异的是，把 <code>document.body.innerHTML = ''</code> 放在 <code>afterEach</code> 的开头反而报错，放在结尾就正常。</p>

<p><strong>根因</strong>：Vue 的调度器不知道测试环境的存在。如果在 Vue 还没完成 last tick 的 DOM patch 时清空 body，等它去 patch 时父节点已经是 null。</p>

<p><strong>解法</strong>：正确的清理顺序必须是：<code>卸载所有 wrapper / destroyAllOverlays → flushPromises（若用了 fake timers 则 useRealTimers 还原）→ 清空 body → restoreAllMocks</code>。</p>

<p><strong>这是最容易忽略的一条</strong>。很多人（包括我）第一反应是&rdquo;清空 body 嘛，什么时候不行&rdquo;，结果 Vue 用崩溃告诉你不可以。</p>

<hr>

<h3 id="陷阱-5-v-model-关闭后立刻断言事件-但-dom-patch-还没发生">陷阱 5：<code>v-model</code> 关闭后立刻断言事件，但 DOM patch 还没发生</h3>

<p><strong>表现</strong>：点击关闭按钮后，<code>wrapper.emitted('update:modelValue')</code> 有值，但紧接着查询 <code>document.body.querySelector('.mg-modal')</code> 仍能查到（元素还在）。</p>

<p><strong>根因</strong>：</p>

<p><code>emit('update:modelValue', false)</code> 是同步的，但 Teleport 移除 DOM 是<strong>异步 patch</strong>。事件已派发、DOM 还没更新。</p>

<p><strong>解法</strong>：</p>

<ol>
<li><strong>先操作 UI 再断言事件</strong>：<code>wrapper.emitted(...)</code> 是同步的，点击后立即可用</li>
<li><strong>DOM 断言必须等待异步 patch</strong>：<code>await flushPromises()</code> 或 <code>await wrapper.vm.$nextTick()</code></li>
<li>先断言事件（同步派发），再断言 DOM（异步更新），在时间线上分离二者：</li>
</ol>

<pre><code class="language-ts">// ✅ 推荐写法：先触发 UI，事件同步断言；再等 DOM 异步更新
closeBtn.click()
expect(wrapper.emitted(&quot;update:modelValue&quot;)).toBeTruthy() // 事件是同步派发的
await flushPromises() // 等待 DOM 移除
expect(document.body.querySelector(&quot;.mg-modal&quot;)).toBeNull()
</code></pre>

<hr>

<h3 id="小结-teleport-测试的黄金法则">小结：Teleport 测试的黄金法则</h3>

<table>
<thead>
<tr>
<th>法则</th>
<th>一句话</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>挂载</strong></td>
<td>一律 <code>attachTo: document.body</code></td>
</tr>

<tr>
<td><strong>断言</strong></td>
<td>Teleport 内容用 <code>document.body.querySelector</code></td>
</tr>

<tr>
<td><strong>卸载</strong></td>
<td>显式 <code>await wrapper.unmount()</code>，不依赖自动清理</td>
</tr>

<tr>
<td><strong>清理</strong></td>
<td>卸载 → <code>flushPromises()</code>（用了 fake timers 则 <code>useRealTimers()</code> 还原，勿用 <code>runAllTimersAsync()</code> 以免死循环）→ 清空 body，顺序不可颠倒</td>
</tr>

<tr>
<td><strong>时序</strong></td>
<td>事件同步，DOM 异步，断言前先 <code>flushPromises()</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第二部分-测试驱动顺手抓到的-2-个真实-bug">第二部分：测试驱动顺手抓到的 2 个真实 Bug</h2>

<p>写测试的过程中，失败的断言意外暴露了组件库本身的两个隐藏缺陷。这才是测试真正的价值——<strong>它不只是验证代码，而是替用户提前踩坑</strong>。</p>

<h3 id="bug-1-input-组件的-change-事件完全丢失">Bug 1：Input 组件的 <code>change</code> 事件完全丢失</h3>

<p><strong>表现</strong>：组件声明了 <code>change</code> 事件，但无论如何测试都收不到：</p>

<pre><code class="language-ts">// Input.vue 中声明了
const emit = defineEmits&lt;{
  /** 值变化时触发（原生事件透传） */
  change: [event: Event]
}&gt;()

// 但测试始终收不到
wrapper.trigger(&quot;change&quot;)
expect(wrapper.emitted(&quot;change&quot;)).toHaveLength(1) // ❌ 失败
</code></pre>

<p><strong>根因</strong>：模板里只绑定了 <code>@input</code>、<code>@blur</code>、<code>@focus</code>，<strong>漏掉了 <code>@change</code></strong>。要理解为什么事件会&rdquo;彻底消失&rdquo;，需要先弄清 Vue 3 的事件透传机制：</p>

<blockquote>
<p>Vue 3 中，组件模板上监听的<strong>未在 <code>emits</code> 中声明</strong>的事件，会被当作原生事件透传给根元素（进入 <code>$attrs</code>）；<strong>一旦在 <code>emits</code> 中声明</strong>，Vue 就认为该事件已由组件内部显式处理，不再透传。</p>
</blockquote>

<p>因此，<code>change</code> 被 <code>defineEmits</code> 声明后，Vue 不会再把它透传给根 <code>&lt;input&gt;</code>。此时如果模板中又没有绑定 <code>@change</code> 处理器，这个事件就<strong>凭空消失</strong>了——既不会触发组件事件，也不会透传到原生元素。</p>

<pre><code class="language-vue">&lt;!-- 修复前：漏了 @change --&gt;
&lt;input @input=&quot;handleInput&quot; @blur=&quot;handleBlur&quot; @focus=&quot;handleFocus&quot; /&gt;

&lt;!-- 修复后 --&gt;
&lt;input
  @input=&quot;handleInput&quot;
  @blur=&quot;handleBlur&quot;
  @focus=&quot;handleFocus&quot;
  @change=&quot;handleChange&quot;
/&gt;
</code></pre>

<p><strong>教训</strong>：</p>

<p><code>defineEmits</code> 声明的事件如果没在模板绑定对应处理器，会被&rdquo;吞掉&rdquo;（既不触发、也不透传）。<strong>这条例外适用于所有组件库</strong>——尤其是表格、表单这类依赖原生事件透传的组件。</p>

<hr>

<h3 id="bug-2-createoverlay-共享容器的孤儿引用">Bug 2：<code>createOverlay</code> 共享容器的孤儿引用</h3>

<p><strong>表现</strong>：测试清空 body 后，下一个用例创建 Message/Toast，查询容器时莫名失败。</p>

<p><strong>根因</strong>：我们的 <code>createOverlay</code> 用模块级 <code>Map</code> 缓存共享容器（用于消息堆叠）：</p>

<pre><code class="language-ts">// createOverlay.ts
const sharedContainers = new Map&lt;string, HTMLDivElement&gt;()

function getSharedContainer(containerClass: string) {
  let container = sharedContainers.get(containerClass)
  if (!container) {
    container = document.createElement(&quot;div&quot;)
    document.body.appendChild(container)
    sharedContainers.set(containerClass, container)
  }
  return container // ❌ 如果容器已被外部 remove，这里返回的是&quot;孤儿节点&quot;
}
</code></pre>

<p>当测试执行 <code>document.body.innerHTML = ''</code> 后，Map 里的容器引用<strong>已脱离 DOM</strong>（<code>isConnected === false</code>），但缓存没清空。下一个用例调用 <code>createOverlay</code> 时拿到孤儿节点，内容挂进去后从 <code>document.body</code> 查不到 → 失败。</p>

<p><strong>解法</strong>：销毁逻辑增加 <code>isConnected</code> 检测，并提供同步清理 API：</p>

<pre><code class="language-ts">// ① 容器可能已被外部移除时，同步从 DOM 摘除
if (container.childElementCount === 0 || !container.isConnected) {
  container.remove()
  sharedContainers.delete(containerClass)
}

// ② 新增同步清理 API，供测试/应用卸载使用
export function destroyAllOverlays(): void {
  activeInstances.forEach((instance) =&gt; {
    instance.element.remove()
    instance.app.unmount()
  })
  sharedContainers.clear()
  activeInstances.clear()
}
</code></pre>

<p><strong>教训</strong>：动态挂载（<code>createApp</code> 手动管理生命周期）的工具，<strong>绝不能只依赖&rdquo;元素存在&rdquo;来判断是否复用</strong>——必须检测元素是否仍连接在文档树中（<code>isConnected</code>）。</p>

<hr>

<h2 id="附-可复现的最小示例">附：可复现的最小示例</h2>

<p>如果你想在本地复现这 5 个陷阱，下面是最小化的模板（完整可跑代码见 <a href="https://github.com/yuelinghuashu/moongate-vue/tree/main/src/__tests__" target="_blank">Moongate Vue 仓库 <code>src/__tests__</code></a>）：</p>

<pre><code class="language-vue">&lt;!-- MyTeleport.vue --&gt;
&lt;template&gt;
  &lt;div&gt;
    &lt;Teleport to=&quot;body&quot;&gt;
      &lt;div v-if=&quot;visible&quot; class=&quot;my-overlay&quot;&gt;overlay content&lt;/div&gt;
    &lt;/Teleport&gt;
    &lt;button @click=&quot;visible = !visible&quot;&gt;toggle&lt;/button&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
import { ref } from &quot;vue&quot;
const visible = ref(true)
&lt;/script&gt;
</code></pre>

<pre><code class="language-ts">// my-teleport.test.ts —— 复现&quot;不敢在 afterEach 清空 body&quot;之坑
import { describe, it, expect, afterEach } from &quot;vitest&quot;
import { mount, flushPromises } from &quot;@vue/test-utils&quot;
import MyTeleport from &quot;./MyTeleport.vue&quot;

describe(&quot;MyTeleport&quot;, () =&gt; {
  afterEach(async () =&gt; {
    // ❌ 错误：先清空 body → Vue patch 崩溃
    // document.body.innerHTML = ''
    // ✅ 正确：先 flush 再清空
    await flushPromises()
    document.body.innerHTML = &quot;&quot;
  })

  it(&quot;Teleport 内容在 body 而非 wrapper&quot;, () =&gt; {
    const wrapper = mount(MyTeleport, { attachTo: document.body })
    expect(wrapper.find(&quot;.my-overlay&quot;).exists()).toBe(false) // wrapper 查不到
    expect(document.body.querySelector(&quot;.my-overlay&quot;)).not.toBeNull() // body 才能查到
  })
})
</code></pre>

<hr>

<h2 id="结语">结语</h2>

<p>这次为组件库补测试的经历，最大收获不是&rdquo;写出了 204 个通过的断言&rdquo;，而是验证了一个观点：<strong>测试环境（jsdom）不是浏览器，但它会以更严格的方式逼你直面组件实现和生命周期管理的每一处模糊地带。</strong></p>

<p>Teleport 在 jsdom 中的这些坑，本质都是同一个问题：<strong>手动挂载（Teleport / createApp / 动态组件）产生的 DOM，其生命周期超出了组件 vnode 树的管理范围。这种&rdquo;失控&rdquo;在 jsdom 中会被放大——就像 Bug 2 中的孤儿引用，你以为清理干净了，实则残留的缓存和游离的 DOM 节点还在暗中作祟</strong>。理解了这一点，就掌握了应对所有类似场景（Portal、Dialog、Notification、ContextMenu）的思路。</p>

<hr>

<h2 id="关于-moongate-vue">🌙 关于 Moongate Vue</h2>

<p>本文基于 <a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">Moongate Vue</a> 的真实测试实践，相关资源：</p>

<ul>
<li><strong>项目仓库</strong>：<a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">github.com/yuelinghuashu/moongate-vue</a> — 极简 Vue 3 组件库，零依赖、CSS 优先、25KB gzip</li>
<li><strong>真实案例</strong>：<a href="https://moongate.top" target="_blank">moongate.top</a> — 个人博客，从 Nuxt UI v4 迁移至 Moongate Vue 构建</li>
<li><strong>在线文档</strong>：<a href="https://vue.moongate.top" target="_blank">vue.moongate.top</a> — 组件 API 与主题定制指南</li>
</ul>
]]></content:encoded>
      <description><![CDATA[为我们的组件库 Moongate Vue 编写 204 个单元测试时，Teleport 组件在 jsdom 环境中踩了 5 个坑，还顺手揪出了 2 个隐藏 bug。本文复盘完整过程，附可复现的最小示例。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[TypeScript]]></category>
      
    </item>

    <item>
      <title><![CDATA[VS Code CompletionProvider 中的 filterText 陷阱]]></title>
      <link>https://moongate.top/docs/vscode-completion-provider-filtertext-trap</link>
      <guid isPermaLink="true">https://moongate.top/docs/vscode-completion-provider-filtertext-trap</guid>
      <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="1-现象">1. 现象</h2>

<p>最近在开发一个 VS Code 扩展，为自定义的 <code>.meph</code> 文件提供语法支持。其中有一个很自然的需求：用户输入 <code>【</code> 时，自动弹出标准区块名的补全列表。</p>

<p>注册方式按官方文档来：</p>

<pre><code class="language-typescript">vscode.languages.registerCompletionItemProvider(
  { language: &quot;mephisto&quot; },
  new MephistoCompletionProvider(),
  &quot;【&quot;, // trigger character
  &quot;.&quot;,
  &quot;[&quot;,
)
</code></pre>

<p>预期行为是：输入 <code>【</code> → 弹出补全 → 选择&rdquo;角色名&rdquo; → 自动变成 <code>【角色名】</code>。</p>

<p>实际表现：输入 <code>【</code> 后，auto-closing 自动补了 <code>】</code>，行变成 <code>【】</code>，但<strong>补全列表什么都没有</strong>。</p>

<h2 id="2-第一次排查-triggercharacter-的问题">2. 第一次排查：triggerCharacter 的问题</h2>

<p>起初怀疑是 triggerCharacter 没生效。在 <code>provideCompletionItems</code> 开头加了一行测试代码：</p>

<pre><code class="language-typescript">provideCompletionItems() {
    return [new vscode.CompletionItem('测试-如果能看到我', CompletionItemKind.Text)];
    // ... 原有逻辑
}
</code></pre>

<p>重新加载扩展后，按 <code>Ctrl+Space</code> 手动触发补全，&rdquo;测试-如果能看到我&rdquo; <strong>正常显示</strong>。</p>

<p>这说明 completion provider 本身注册成功了，也能正常返回数据。问题出在<strong>触发环节</strong>。</p>

<p>进一步测试发现，在中文输入法下 <code>'【'</code> 作为 trigger character 并不可靠。不是 <code>【</code> 本身不能作为 trigger——在英文输入法下直接输入 <code>【</code> 是可以触发补全的。问题在于中文输入法（如拼音）中，<code>【</code> 通常通过候选窗口选择输入，这个输入路径绕过了 VS Code 的 trigger character 检测机制。</p>

<p>改用 <code>onDidChangeTextDocument</code> 事件配合 <code>triggerSuggest</code> 命令，不依赖 trigger character，而是监听文档内容变化后强制弹出补全：</p>

<pre><code class="language-typescript">vscode.workspace.onDidChangeTextDocument((e) =&gt; {
  if (e.document.languageId === &quot;mephisto&quot;) {
    for (const change of e.contentChanges) {
      if (change.text === &quot;【&quot;) {
        vscode.commands.executeCommand(&quot;editor.action.triggerSuggest&quot;)
        break
      }
    }
  }
})
</code></pre>

<p>注意这里<strong>不需要</strong> <code>setTimeout(0)</code>。根据 VS Code 的事件模型，<code>onDidChangeTextDocument</code> 在文档内容修改完成后才触发，此时 auto-closing 已经同步插入了 <code>】</code>，当前行已经是 <code>【】</code> 了。不过在极慢的扩展宿主环境下，<code>setTimeout(0)</code> 可以作为保险措施，但通常不需要。</p>

<h2 id="3-第二次排查-补全数据去哪了">3. 第二次排查：补全数据去哪了</h2>

<p>用 <code>onDidChangeTextDocument</code> 替换 triggerCharacter 后，provider 确实被调用了，也返回了 9 个标准区块名。但<strong>补全列表依然是空的</strong>。</p>

<p>没有报错，没有警告，只有空荡荡的补全弹窗。</p>

<p>这就奇怪了：provider 返回了数据，VS Code 没有报错，为什么用户看不到？</p>

<h2 id="4-真正的原因-filtertext">4. 真正的原因：filterText</h2>

<p>问题出在 VS Code 的补全过滤机制上。</p>

<p>当用户输入 <code>【</code> 后，auto-closing 立即插入 <code>】</code>，当前行变成了：</p>

<pre><code class="language-text">【】
</code></pre>

<p>光标在索引 1 的位置（<code>【</code> 和 <code>】</code> 之间）。此时 <code>triggerSuggest</code> 弹出补全，VS Code <strong>自动提取光标位置的&rdquo;当前词&rdquo;</strong>作为过滤前缀。在 <code>【】</code> 中间，当前词就是 <code>【</code>。</p>

<p>然后 VS Code 拿 <code>【</code> 去匹配 provider 返回的每个补全项：</p>

<table>
<thead>
<tr>
<th>补全项 label</th>
<th>默认用于匹配的文本（filterText = label）</th>
<th>匹配 <code>【</code>？</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>角色名</code></td>
<td><code>角色名</code></td>
<td>❌</td>
</tr>

<tr>
<td><code>锚点</code></td>
<td><code>锚点</code></td>
<td>❌</td>
</tr>

<tr>
<td><code>世界观</code></td>
<td><code>世界观</code></td>
<td>❌</td>
</tr>

<tr>
<td>&hellip;</td>
<td>&hellip;</td>
<td>❌</td>
</tr>
</tbody>
</table>
<p>全部不匹配，所以全部被过滤掉。</p>

<p>这就是 <code>filterText</code> 的作用：它告诉 VS Code <strong>用什么文本来做匹配</strong>，而不是用 label 作为匹配依据。</p>

<p>修复方式是在 <code>getBlockCompletions</code> 中设置 <code>filterText</code>：</p>

<pre><code class="language-typescript">private getBlockCompletions(): CompletionItem[] {
    const items: CompletionItem[] = [];
    for (const name of STANDARD_BLOCKS) {
        const item = new CompletionItem(name, CompletionItemKind.Module);
        item.insertText = name + '】\n';
        item.detail = '标准区块';
        item.filterText = '【' + name;  // ← 关键
        items.push(item);
    }
    return items;
}
</code></pre>

<ul>
<li><code>label</code>（显示文本）仍然是&rdquo;角色名&rdquo;</li>
<li><code>insertText</code>（插入内容）仍然是 <code>角色名】\n</code></li>
<li>但 <code>filterText</code> 设为 <code>'【角色名'</code>，VS Code 拿到当前词 <code>【</code> 去匹配 <code>'【角色名'</code> → <strong>匹配成功</strong> → 显示在列表中</li>
</ul>

<h2 id="5-completionitem-的文本相关属性">5. CompletionItem 的文本相关属性</h2>

<p><code>CompletionItem</code> 有三个与文本相关的属性：</p>

<table>
<thead>
<tr>
<th>属性</th>
<th>作用</th>
<th>在这个场景中的值</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>label</code></td>
<td>用户看到的补全项文本</td>
<td><code>角色名</code></td>
</tr>

<tr>
<td><code>filterText</code></td>
<td>VS Code 用于做模糊匹配的文本</td>
<td><code>【角色名</code></td>
</tr>

<tr>
<td><code>insertText</code></td>
<td>用户选择后实际插入的文本</td>
<td><code>角色名】\n</code></td>
</tr>
</tbody>
</table>
<p>默认情况下，<code>filterText</code> 等于 <code>label</code>。当补全项 label 与触发字符（当前词）不匹配时，需要显式设置 <code>filterText</code> 来提供正确的匹配文本。</p>

<p>值得注意的是，VS Code 的过滤是 <strong>fuzzy matching（模糊匹配）</strong>，不是严格的前缀匹配。<code>filterText</code> 的值会参与评分算法，当前词不需要严格作为 <code>filterText</code> 的前缀也能匹配。但在这个场景中，设 <code>filterText</code> 为 <code>'【角色名'</code> 已经足够让 <code>【</code> 匹配上了。</p>

<p>此外，<code>sortText</code> 控制补全列表的排序顺序：</p>

<pre><code class="language-typescript">item.sortText = &quot;0&quot; + name // 标准区块排在前面
item.sortText = &quot;1&quot; + name // 其他项排在后面
</code></pre>

<p><code>CompletionItemKind</code> 则影响显示的图标：</p>

<pre><code class="language-typescript">item.kind = CompletionItemKind.Module // 方块图标
item.kind = CompletionItemKind.Class // 菱形图标
item.kind = CompletionItemKind.Struct // 结构体图标
</code></pre>

<p>挑一个视觉上顺眼的即可。</p>

<h2 id="6-最终代码">6. 最终代码</h2>

<p>将上述改动整合在一起，最终实现如下：</p>

<pre><code class="language-typescript">// extension.ts — 监听中文输入法下的 【 输入
vscode.workspace.onDidChangeTextDocument(e =&gt; {
    if (e.document.languageId !== 'mephisto') return;
    for (const change of e.contentChanges) {
        if (change.text === '【') {
            vscode.commands.executeCommand('editor.action.triggerSuggest');
            break;
        }
    }
});

// completion.ts — CompletionProvider 中设置 filterText 和 range
private getBlockCompletions(): CompletionItem[] {
    return STANDARD_BLOCKS.map(name =&gt; {
        const item = new CompletionItem(name, CompletionItemKind.Module);
        item.insertText = name + '】\n';
        item.filterText = '【' + name;
        item.detail = '标准区块';
        return item;
    });
}
</code></pre>

<h2 id="7-总结">7. 总结</h2>

<h3 id="核心要点回顾">核心要点回顾</h3>

<table>
<thead>
<tr>
<th>问题</th>
<th>原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td>输入 <code>【</code> 不触发补全</td>
<td>中文输入法下 triggerCharacter 不可靠</td>
<td>改用 <code>onDidChangeTextDocument</code> + <code>triggerSuggest</code></td>
</tr>

<tr>
<td>补全弹出了但列表为空</td>
<td><code>filterText</code> 默认与 label 相同，无法匹配当前词 <code>【</code></td>
<td>设置 <code>filterText</code> 为 <code>'【' + name</code></td>
</tr>

<tr>
<td>VS Code 提取的当前词不对</td>
<td><code>wordPattern</code> 不包含 <code>【</code></td>
<td>设置 <code>range</code> 手动指定当前词范围</td>
</tr>
</tbody>
</table>

<h3 id="一句话总结">一句话总结</h3>

<p><code>filterText</code> 解决的是&rdquo;<strong>拿什么去匹配</strong>&ldquo;的问题，<code>range</code> 解决的是&rdquo;<strong>当前要匹配的词是哪个</strong>&ldquo;的问题。两者结合，能应对大多数补全过滤异常的场景。</p>

<h3 id="最终的完整解决方案">最终的完整解决方案</h3>

<pre><code class="language-typescript">// 1. 监听输入触发补全（不依赖 triggerCharacter）
vscode.workspace.onDidChangeTextDocument(e =&gt; {
    if (e.document.languageId !== 'mephisto') return;
    for (const change of e.contentChanges) {
        if (change.text === '【') {
            vscode.commands.executeCommand('editor.action.triggerSuggest');
            break;
        }
    }
});

// 2. 在 CompletionProvider 中设置 filterText 和 range
provideCompletionItems(document: vscode.TextDocument, position: vscode.Position) {
    const line = document.lineAt(position.line).text;
    const openIdx = line.indexOf('【');
    const closeIdx = line.indexOf('】');

    if (openIdx !== -1 &amp;&amp; closeIdx !== -1 &amp;&amp; position.character &gt; openIdx &amp;&amp; position.character &lt;= closeIdx) {
        const items = STANDARD_BLOCKS.map(name =&gt; {
            const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Module);
            item.insertText = name + '】\n';
            item.filterText = '【' + name;
            item.range = new vscode.Range(
                new vscode.Position(position.line, openIdx),
                new vscode.Position(position.line, openIdx + 1)
            );
            item.detail = '标准区块';
            return item;
        });
        return items;
    }
    // 其他补全逻辑...
}
</code></pre>

<h3 id="排查清单">排查清单</h3>

<p>当你在 VS Code 扩展中发现补全不显示时：</p>

<ol>
<li>补全列表完全没出现 → 检查 triggerCharacter / triggerSuggest 是否生效</li>
<li>补全列表弹出了但什么都没有 → 检查 provider 是否返回了数据（加一个测试项验证）</li>
<li>确认返回了数据但用户看不到 → <strong>检查 filterText 是否匹配当前词</strong></li>
<li>以上都无效 → 检查 wordPattern 或设置 range</li>
</ol>

<p>其中第 3 步是最容易忽略的，因为 VS Code 不会告诉你它过滤掉了什么。</p>
]]></content:encoded>
      <description><![CDATA[VS Code 扩展开发中 CompletionProvider 返回数据但补全列表为空的完整解析，涵盖 filterText 过滤机制、中文输入法下 triggerCharacter 失效问题，以及系统的排查与解决方案。]]></description>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：运行时闭环与多分支存档]]></title>
      <link>https://moongate.top/docs/narrative-engine-runtime-loop-and-branching</link>
      <guid isPermaLink="true">https://moongate.top/docs/narrative-engine-runtime-loop-and-branching</guid>
      <pubDate>Mon, 20 Jul 2026 23:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-叙事引擎的第五个问题-怎么让规则活起来">一、叙事引擎的第五个问题：怎么让规则活起来？</h2>

<p>解析器把契约变成了结构体，但结构体不会叙事。一个叙事引擎还需要<strong>运行时</strong>——输入 → 匹配 → 执行 → 输出的完整闭环。</p>

<p>这里要解决的工程问题有三个：</p>

<ol>
<li><strong>规则怎么实时匹配？</strong> 用户输入到达时，引擎需要遍历所有规则、评估条件、决定触发哪些动作——这个过程必须在毫秒级完成，不能影响用户体验。</li>
<li><strong>LLM 怎么遵守规则？</strong> 规则不能直接约束 LLM 的行为，它们必须通过 Prompt 间接生效。怎么把规则&rdquo;翻译&rdquo;成 LLM 能理解的格式，是这个环节的核心难点。</li>
<li><strong>状态和记忆怎么持续？</strong> 每一轮对话都会改变角色的状态和记忆池，但下一轮对话开始时，引擎必须恢复上一轮的完整状态。没有持久化，就没有长线叙事。</li>
</ol>

<p>这一篇解决的就是这三个问题。第四、五篇解决的规则匹配细节（两阶段匹配、骰子判定）此处不再展开，聚焦运行时如何把整条链路串起来。</p>

<hr>

<p>引擎拿到 <code>contract</code> 后进入一个循环：接收用户输入 → 匹配规则 → 执行动作 → 调用 LLM → 返回响应。这个循环构成了引擎的运行时。</p>

<p>但在实现这个循环之前，有几个工程问题必须先解决——它们决定了引擎是&rdquo;能跑&rdquo;还是&rdquo;能用&rdquo;。</p>

<h2 id="二-小说级终端流-全角缩进与流式拦截">二、小说级终端流：全角缩进与流式拦截</h2>

<p>LLM 的流式输出是逐块返回的。引擎通过 <code>onChunk</code> 回调拦截每一块，然后直接写到终端。</p>

<p>这里有一个设计细节：<strong>全角缩进（<code>　　</code>）</strong>。</p>

<pre><code class="language-go">onChunk := func(chunk string) {
    for _, ch := range chunk {
        if ch == '\n' {
            fmt.Println()
            needIndent = true
            inParagraph = false
        } else {
            if !inParagraph &amp;&amp; needIndent {
                fmt.Print(&quot;　　&quot;)  // 每个段落开头空两格
                needIndent = false
            }
            fmt.Print(string(ch))
            inParagraph = true
        }
    }
}
</code></pre>

<p>效果：终端输出的文本，每个自然段开头都有两个全角空格，看起来像一本实体书。这个细节对创作者的体验提升是巨大的——它把&rdquo;终端输出&rdquo;变成了&rdquo;小说页面&rdquo;。</p>

<p>但流式输出背后有一个更关键的工程决策：<strong>动作执行器统一处理流式回调</strong>。</p>

<ul>
<li>状态修改动作：立即返回结果，然后通过回调模拟逐字符输出</li>
<li>LLM 动作：真正的流式输出，逐块回调</li>
<li>静态文本：立即返回，然后模拟流式输出</li>
</ul>

<p>所有动作最终都通过 <code>onChunk</code> 输出，不管来源是什么。这保证了终端的输出体验是一致的。</p>

<h2 id="三-五层三明治-prompt-把约束焊死在上下两端">三、五层三明治 Prompt：把约束焊死在上下两端</h2>

<p>LLM 叙事最大的问题是格式跑偏——它经常输出括号剧本流：</p>

<pre><code class="language-text">（冷笑一声）【贝利亚】：你们太弱了。
</code></pre>

<p>这破坏了沉浸感。解决方案是<strong>把格式约束放在 Prompt 的顶部和底部</strong>，形成三明治结构。v1.1.0 的实际 Prompt 渲染（<code>internal/core/llm/prompt.go</code> 的 <code>RenderPrompt</code>）是五层结构：</p>

<pre><code class="language-text">【格式硬性要求】
（NarrativeConstraints——禁止括号、禁止剧本标记、禁止独角戏）

【世界观】
（context）

【角色名】
你是 {角色名}，一个 {锚点风格} 的存在。你的背景：{角色背景}

【当前状态】
（从运行时 `map[string]any` 渲染而来）

【命运的推动】
（对话历史——命运与角色的交替记录）

【你记得的过往】
（运行时动态累积的记忆）

【此刻】
（user_input）

【要求】
（NarrativeConstraints，再次强调）
</code></pre>

<p>约束在两端出现两次，中间夹着上下文。这样做有两个原因：</p>

<ol>
<li><strong>首因效应</strong>：LLM 最先看到约束，优先级最高</li>
<li><strong>近因效应</strong>：LLM 最后看到的约束会影响最终输出</li>
</ol>

<p>两次强调，确保格式约束不会被中间的上下文稀释。</p>

<h3 id="3-1-确定性渲染-排序保证-cache-命中">3.1 确定性渲染：排序保证 Cache 命中</h3>

<p>这里有一个微妙的工程问题：Go 的 <code>map</code> 遍历顺序是随机的。</p>

<p>在引擎运行时，契约中的 <code>【状态】</code> 会被转换为 <code>map[string]any</code> 以便快速读写。当 Prompt 渲染 <code>【当前状态】</code> 时，如果每次遍历顺序不同，生成的 Prompt 文本就会变化——哪怕状态内容完全一样。这会导致 <strong>LLM 的 KV Cache 完全失效</strong>，每次都要重新计算。</p>

<p>解决方案：对 <code>state</code> 的键做排序（<code>sort.Strings</code>），确保渲染顺序稳定。这样在状态不变时，生成的 Prompt 文本字节级一致，LLM 服务的 KV Cache 能够命中，降低延迟和 Token 消耗。</p>

<h3 id="3-2-反独角戏约束">3.2 反独角戏约束</h3>

<p><code>NarrativeConstraints</code> 中有一条被刻意强调：</p>

<blockquote>
<p>每段回复必须包含至少一名其他角色（非玩家）的对话和动作反应。如果场景中没有其他角色，请引入或创造至少一个互动对象。禁止只有玩家独角戏。</p>
</blockquote>

<p>这是为了解决叙事中的&rdquo;空旷感&rdquo;。如果 LLM 只回应玩家输入，不引入其他角色互动，故事会变成单人独白，失去戏剧张力。这条约束强制 LLM 在每轮回复中至少引入一个互动对象，让世界活起来。</p>

<h2 id="四-mother-child-存档机制">四、Mother-Child 存档机制</h2>

<p>每一轮对话结束后，引擎会自动保存当前状态到子版文件。</p>

<h3 id="命名规则">命名规则</h3>

<ul>
<li>母版 <code>story.meph</code> → 默认子版 <code>story.child.meph</code></li>
<li>分支 <code>--branch dark</code> → <code>story.dark.meph</code></li>
</ul>

<pre><code class="language-go">func BuildChildPath(filename string, branch string) string {
    dir := filepath.Dir(filename)
    base := filepath.Base(filename)
    ext := filepath.Ext(base)
    name := strings.TrimSuffix(base, ext)

    // 已是子版：直接覆盖（避免嵌套生成）
    if isChildFileName(name) {
        return filename
    }

    if branch != &quot;&quot; {
        return filepath.Join(dir, name+&quot;.&quot;+branch+ext)
    }
    return filepath.Join(dir, name+childSuffix+ext) // childSuffix = &quot;.child&quot;
}
</code></pre>

<p><code>isChildFileName</code> 精准识别已存在的子版：<code>xxx.child</code>（默认子版）或 <code>xxx.分支名</code>（分支名以字母开头）。这样 <code>my_story_1.meph</code>（数字序号）不会被误判为子版。</p>

<p>子版文件是完整的 <code>.meph</code> 契约，包含：</p>

<ul>
<li>母版的所有静态区块（角色名、世界观、角色背景、开局场景、锚点、规则）</li>
<li>更新后的 <code>【状态】</code></li>
<li>累积的 <code>【记忆】</code></li>
<li>最近的 <code>【历史】</code></li>
</ul>

<p>这意味着一份静态契约可以演化出无数个动态子版：</p>

<pre><code class="language-text">story.meph (母版，只读)
    ├── story.child.meph (主线存档)
    ├── story.dark.meph (黑暗分支)
    ├── story.light.meph (光明分支)
    └── story.experimental.meph (实验分支)
</code></pre>

<p>每个分支独立演化，互不影响。项目自带的 <code>data/dantes.meph</code> 就伴随一个已运行的 <code>data/dantes.child.meph</code> 存档。</p>

<h3 id="保存时机">保存时机</h3>

<p>不是每轮都写磁盘的低效模式，而是分两层：</p>

<ol>
<li><strong>每轮对话后</strong>：Session 层（<code>cmd/mephisto/session.go</code>）调用 <code>engine.Save()</code>，实时持久化进度</li>
<li><strong>退出时</strong>：<code>defer</code> 再保存一次，确保退出前状态落盘</li>
</ol>

<h3 id="保存时的规则保鲜">保存时的规则保鲜</h3>

<p><code>Save()</code> 有一个精妙设计——保存前先读取磁盘上的子版文件（若存在），以磁盘上的 <code>【规则】</code> 区块为最新规则。这样用户在编辑器中对规则区块的实时修改不会被自动保存覆盖。这同时支撑了 v1.0.3 引入的<strong>规则热重载</strong>：<code>session.go</code> 通过 <code>fsnotify</code> 监听子版文件变更，500ms 防抖后调用 <code>ReloadContract</code> 重新解析，只替换规则、保留状态和历史，让&rdquo;编辑规则 → 保存 → 立即生效&rdquo;成为可能。</p>

<h3 id="加载时">加载时</h3>

<ul>
<li>默认加载子版（如果存在）</li>
<li><code>--reset</code> 忽略子版，从母版重新开始</li>
<li><code>--branch dark</code> 加载对应的分支文件</li>
</ul>

<h3 id="注意">注意</h3>

<p>直接运行子版文件会覆盖原文件——引擎会将任何 <code>.meph</code> 文件视为母版，并生成对应的子版。如果不想丢失进度，请避免对子版文件直接运行 <code>run</code> 命令。</p>

<h3 id="这个设计的价值">这个设计的价值</h3>

<p>创作者可以在关键时刻分叉故事线，探索不同走向，而不丢失任何进度。</p>

<h2 id="五-记忆提取-流式输出后的同步编织">五、记忆提取：流式输出后的同步编织</h2>

<p>记忆提取是长线叙事的关键——它把关键事件从对话历史中提取出来，压缩后长期保存，在每一轮中注入 LLM 上下文。</p>

<p>但提取需要调用 LLM，会耗时数秒。如果放在流式输出<strong>之前</strong>执行，用户每 5 轮就要等几秒才能看到第一个字。</p>

<p>解决方案很简单：<strong>先输出，后提取。</strong></p>

<p>每一轮对话的流程是这样的：</p>

<pre><code class="language-text">用户输入
    │
    ▼
规则匹配 + 动作执行 + LLM 流式输出（用户看到文字逐字出现）
    │
    ▼
流式输出完成，用户读完回复
    │
    ▼
引擎 Run 同步执行记忆提取（此时代码在 Run 内部，用户已读完响应）
    │
    ▼
返回给 Session，Session 调用 Save 自动保存子版
    │
    ▼
显示输入提示，等待下一轮
</code></pre>

<p>注意<strong>和旧版本的区别</strong>：记忆提取在 <code>engine.Run()</code> 内部同步执行，子版保存由 Session 层在每轮 Run 返回后调用。两者解耦——引擎负责叙事与记忆，Session 负责持久化。</p>

<pre><code class="language-go">// internal/core/engine/engine.go

func (e *Engine) Run(input string, onChunk func(string)) (string, error) {
    // ... 规则匹配、LLM 调用、流式输出 ...

    // 4. 记录角色响应
    runtime.AddHistory(&quot;assistant&quot;, response)

    // 5. 记忆提取（每 N 轮，同步调用）
    e.processMemories()   // ← 用户此时已读完响应

    return response, nil
}
</code></pre>

<p><code>processMemories()</code> 内部流程：</p>

<ol>
<li><strong>提取间隔判断</strong>：<code>ShouldExtract</code>——轮数 % 5 == 0 时触发（<code>ExtractInterval = 5</code>）</li>
<li><strong>调用 LLM 提取</strong>：取最近 10 轮对话（<code>ExtractWindow = 10</code>），生成关键事件摘要（每条不超过 20 字）</li>
<li><strong>语义去重</strong>：<code>shared.DeduplicateMemories</code> 基于关键词 Jaccard 相似度去重，语义相近但表述不同的记忆自动合并。例如&rdquo;浮士德在书斋中遇到了梅菲斯特&rdquo;与&rdquo;梅菲斯特在深夜来访浮士德的书斋&rdquo;——两条记忆共享浮士德、梅菲斯特、书斋等多个关键词，被判定为描述同一事件而合并成一条</li>
<li><strong>追加 + 压缩</strong>：超过上限（<code>MaxLimit = 30</code>）时自动压缩，保留最近 5 条 + 3-5 条摘要</li>
</ol>

<h3 id="为什么这样设计">为什么这样设计？</h3>

<ol>
<li><strong>用户无感知</strong>：流式输出已经完成，用户正在阅读或思考回复内容。记忆提取在后台悄悄进行，用户不需要&rdquo;等待&rdquo;。</li>
<li><strong>逻辑简单</strong>：同步调用比异步 goroutine 更容易控制——没有竞态条件，没有&rdquo;保存时记忆还没写完&rdquo;的问题。</li>
</ol>

<p>提取失败只会静默记录一条日志（提取函数返回错误则直接跳过），对话可以继续——只是本轮的记忆没有被保存。</p>

<h2 id="六-完整闭环">六、完整闭环</h2>

<p>把所有部分串起来，引擎的每一轮对话是这样运转的：</p>

<pre><code class="language-text">用户输入
    │
    ▼
规则匹配
    │   ├── 被动规则（状态修改 + 注入记忆）批量执行，多条同时触发
    │   └── 主动规则（LLM 指令 / 静态文本）互斥匹配，只取第一条
    │
    ▼
执行动作 → LLM 调用（60 秒超时保护，失败时 ⚠️ 降级为静态响应）
    │                        ↑——超时只保护 LLM 调用阶段
    ▼
流式输出（全角缩进 + 逐块回调）
    │
    ▼
记录 assistant 历史 → （同步）记忆提取（每 5 轮触发一次，无流式等待）
    │
    ▼
Session 层调用 Save → 自动保存到子版文件（story.child.meph）
    │
    ▼
规则热重载监听（后台异步 fsnotify，不阻塞主循环）
    │
    ▼
等待下一轮输入
</code></pre>

<p>几个运行时的健壮性细节值得说明：</p>

<ul>
<li><strong>LLM 超时降级</strong>：整个 LLM 调用包裹在 60 秒超时上下文（<code>context.WithTimeout</code>）中。超时或失败时，引擎通过 <code>onChunk</code> 输出 <code>（⚠️ LLM 调用失败：请求超时，已降级为静态响应）</code>，再返回默认静态文本。<strong>告诉用户&rdquo;LLM 挂了&rdquo;比让用户猜&rdquo;角色沉默了&rdquo;更好</strong>——前者是可诊断的工程问题，后者可能被误解为叙事设计。</li>
<li><strong>调试与静默</strong>：调试信息（<code>--debug</code>）写入 <code>os.Stderr</code>，普通输出（<code>--quiet</code>）不干扰调试信息。两者可同时启用。</li>
</ul>

<p>这就是 Mephisto 的运行时。</p>

<h2 id="七-代价与局限">七、代价与局限</h2>

<p>这套机制不是没有代价的：</p>

<h3 id="1-记忆提取依赖-llm-质量">1. 记忆提取依赖 LLM 质量</h3>

<p>如果主模型状态不佳，提取的摘要可能跑偏——甚至篡改关键事实（如将&rdquo;击败&rdquo;误写为&rdquo;放逐&rdquo;）。这是一种&rdquo;幻觉&rdquo;风险。</p>

<p>当前应对策略有两个层面：</p>

<ul>
<li><strong>提示词保护</strong>：提取和压缩的提示词中明令禁止修改角色的核心设定（角色名、锚点内容、状态值等），在 DeepSeek 和 GPT-4 上效果可靠。</li>
<li><strong>模型选择建议</strong>：对于 7B 本地模型，摘要质量下降明显。如果必须使用轻量模型，建议关闭自动记忆提取（设置 <code>ExtractInterval = 0</code>），改为手动管理记忆。</li>
</ul>

<p>未来可以进一步加强：实现<strong>记忆后验验证</strong>——提取结果返回后，由引擎检查是否与核心设定冲突，发现有矛盾直接丢弃该条记忆。</p>

<h3 id="2-分支切换需要手动管理">2. 分支切换需要手动管理</h3>

<p>子版文件是独立存储的，切换分支需要用户主动指定 <code>--branch</code>。不像真正的版本控制有 diff 和 merge，分支之间的内容不会自动同步。</p>

<h3 id="3-流式输出占用终端">3. 流式输出占用终端</h3>

<p>全角缩进和流式输出在终端看起来很好，但如果用户想复制粘贴文本，缩进和换行符会一起被复制——有时会产生干扰。</p>

<h2 id="八-小结">八、小结</h2>

<p>六篇走完了一条完整的路径：</p>

<table>
<thead>
<tr>
<th>篇目</th>
<th>解决的问题</th>
<th>核心产出</th>
</tr>
</thead>

<tbody>
<tr>
<td>第一篇</td>
<td>用什么格式写规则？</td>
<td><code>.meph</code> 格式设计</td>
</tr>

<tr>
<td>第二篇</td>
<td>第一步跑什么？</td>
<td>从零写浮士德契约并运行</td>
</tr>

<tr>
<td>第三篇</td>
<td>怎么精确解析并报错？</td>
<td>区块扫描器 + Parser</td>
</tr>

<tr>
<td>第四篇</td>
<td>规则和变量怎么解析？</td>
<td>规则表达式 + 插值语法</td>
</tr>

<tr>
<td>第五篇</td>
<td>怎么保证不改坏？</td>
<td>Golden File 测试</td>
</tr>

<tr>
<td>第六篇</td>
<td>怎么让契约活起来？</td>
<td>五层 Prompt + 分支存档 + 记忆提取</td>
</tr>
</tbody>
</table>
<p>合在一起，就是一个完整的长线叙事引擎：</p>

<pre><code>契约（.meph）
    │
    ▼
解析器（第三、四篇）──→ Contract
    │
    ▼
引擎（第六篇）──→ 规则匹配 + LLM 流式叙事 + 记忆提取
    │
    ▼
子版存档（story.child.meph）──→ 长期连续性 + 多分支
</code></pre>

<hr>

<h2 id="速查卡-完整流程">速查卡：完整流程</h2>

<pre><code>.meph 契约文件
    │
    ▼ 扫描器（行号绑定）
    │
    ▼ 区块列表 []Block
    │
    ▼ Parser（按区块标题路由）
    │
    ▼ domain.Contract
    │   ├─ RoleName
    │   ├─ Anchor
    │   ├─ State
    │   ├─ Worldview
    │   └─ Rules（条件原样存储，运行时求值）
    │
    ▼ 引擎
    │   ├─ 五层三明治 Prompt（顶部约束 → 上下文 → 角色 → 状态/历史/记忆 → 底部约束）
    │   ├─ 规则匹配（被动批量 + 主动互斥）
    │   ├─ 动作执行（注入 / 状态修改 / LLM 调用 / 静态文本）
    │   ├─ 流式输出（全角缩进）
    │   ├─ 记忆提取（每 5 轮，同步调用，语义去重）
    │   ├─ LLM 超时降级（60 秒，⚠️ 提示 + 静态响应）
    │   └─ 子版存档（story.child.meph，点分隔命名）
    │
    ▼ 引擎循环
        用户输入 → 规则匹配 → 执行动作 → LLM 流式叙事
        → 记忆提取 → Session 自动保存 → 热重载监听 → 等待下一轮
</code></pre>

<p>如果你想快速定位某篇的具体内容，对照上面的流程节点查找：</p>

<table>
<thead>
<tr>
<th>流程节点</th>
<th>对应文章</th>
<th>关键概念</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>.meph</code> 格式</td>
<td>第一篇</td>
<td>区块标题、规则语法</td>
</tr>

<tr>
<td>上手操作</td>
<td>第二篇</td>
<td>从零写契约、无 LLM 模式</td>
</tr>

<tr>
<td>扫描器</td>
<td>第三篇</td>
<td>行号绑定、白名单前置</td>
</tr>

<tr>
<td>Parser</td>
<td>第四篇</td>
<td>规则拆解、插值语法</td>
</tr>

<tr>
<td>测试体系</td>
<td>第五篇</td>
<td>Golden File、错误场景测试</td>
</tr>

<tr>
<td>引擎运行时</td>
<td>第六篇</td>
<td>五层 Prompt、分支存档、记忆提取</td>
</tr>
</tbody>
</table>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[契约在手，怎么让它活起来？五层三明治 Prompt 结构、流式全角缩进、Mother-Child 分支存档、记忆提取——构建完整的运行时闭环。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：集成测试与行为冻结]]></title>
      <link>https://moongate.top/docs/narrative-engine-integration-testing-and-behavior-freezing</link>
      <guid isPermaLink="true">https://moongate.top/docs/narrative-engine-integration-testing-and-behavior-freezing</guid>
      <pubDate>Mon, 20 Jul 2026 21:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-叙事引擎的第四个问题-怎么保证行为稳定">一、叙事引擎的第四个问题：怎么保证行为稳定？</h2>

<p>解析器写完了。但它是一个<strong>长期维护的代码</strong>——需求会变、格式会扩、bug 会修。每次改动都可能破坏已有的行为。</p>

<p>这里的关键矛盾是：<strong>创作者依赖的是稳定的行为，开发者依赖的是自由的修改权</strong>。如果改一行代码就要手动测试所有已知场景，开发者会畏惧重构；如果不测试，改坏了创作者会直接发现——但创作者不会关心&rdquo;你重构了解析器&rdquo;。</p>

<p>解决方案是把解析行为&rdquo;冻结&rdquo;下来：用一组固定的契约作为看门狗，每次变更后自动对比解析结果是否与预期一致。</p>

<p>这就是集成测试的作用：把一组固定的 <code>.meph</code> 契约作为&rdquo;看门狗&rdquo;，每次代码变更后跑一遍，确保行为没有被意外改变。</p>

<hr>

<h2 id="二-golden-file-测试-把解析结果固化下来">二、Golden File 测试：把解析结果固化下来</h2>

<p>最直接的测试方式：准备一个标准契约文件，解析它，然后把解析结果序列化为 JSON 保存起来。以后每次跑测试，都把当前的解析结果和这个 JSON 文件做对比。</p>

<p>项目里的 <code>testdata/sample.meph</code> 就是这份标准契约。测试流程如下：</p>

<pre><code class="language-go">func TestParseSample(t *testing.T) {
    got, err := ParseFile(&quot;testdata/sample.meph&quot;)
    if err != nil {
        t.Fatalf(&quot;解析失败: %v&quot;, err)
    }

    goldenPath := &quot;testdata/sample.golden&quot;
    var want domain.Contract

    if err := loadGolden(goldenPath, &amp;want); err != nil {
        // Golden 文件不存在，自动生成
        saveGolden(goldenPath, got)
        t.Log(&quot;Golden 文件已生成，请检查后重新运行测试&quot;)
        t.FailNow()
    }

    // 对比 got 和 want
    if diff := cmp.Diff(want, got); diff != &quot;&quot; {
        t.Errorf(&quot;解析结果与预期不符:\n%s&quot;, diff)
        t.Log(&quot;💡 如果更改是预期的，请运行: go test -update&quot;)
    }
}
</code></pre>

<p>首次运行会自动生成 <code>sample.golden</code>。之后每次运行都会对比，发现差异就报错。如果改动是预期的（比如新增了一个字段），运行 <code>go test -update</code> 即可刷新 Golden 文件。</p>

<h3 id="这个机制的核心价值是">这个机制的核心价值是</h3>

<p>让解析器的行为被“冻结”下来。任何改动都必须经过测试验证，不能偷偷改变解析结果。</p>

<h2 id="三-解析即验证">三、解析即验证</h2>

<p>解析不只是“把文本读进来”——它会在解析过程中直接验证必填项。</p>

<p>如果角色名为空，<code>parseRoleName</code> 直接报错：</p>

<pre><code class="language-text">第 X 行：角色名不能为空
</code></pre>

<p>如果规则名/条件/动作为空，<code>parseRuleLine</code> 直接报错并携带行号。</p>

<p><strong>解析不通过，结构体就不存在。</strong> 不存在“解析成功但内容无效”的状态——这是手写解析器相比 JSON/YAML 的另一个优势。JSON 解析器不管语义完整性，它只管结构正确。</p>

<h2 id="四-滑窗老化测试-历史记录的正确截断">四、滑窗老化测试：历史记录的正确截断</h2>

<p>引擎有一个关键行为：历史记录不能无限增长。它需要自动截断，只保留最近 N 轮对话。</p>

<p>测试用例验证这个行为：</p>

<pre><code class="language-go">func TestIntegrationHistoryLimit(t *testing.T) {
    contract, err := parser.ParseFile(&quot;../parser/testdata/sample.meph&quot;)
    if err != nil {
        t.Fatalf(&quot;解析失败: %v&quot;, err)
    }
    // 设置最大历史保留 2 轮
    eng := engine.New(contract, engine.WithMaxHistory(2))

    // 执行 5 轮对话
    for range 5 {
        eng.Run(&quot;你好&quot;, nil)
    }

    history := eng.History()
    // 5 轮对话产生 10 条记录，但容量只有 4 条（2 轮 * 2 条/轮）
    if len(history) != 4 {
        t.Errorf(&quot;历史记录长度 = %d, want 4&quot;, len(history))
    }
}
</code></pre>

<p>这个测试确保历史截断是“整轮丢弃”而不是“逐条丢弃”。如果逐条丢弃，可能出现“只剩下命运的输入、没有角色的响应”这种半轮数据，会导致 Prompt 中 <code>【命运的推动】</code> 区块出现不完整的上下文。</p>

<p><strong>整轮截断</strong>的策略保证了历史的完整性——要么保留一整轮（fate + assistant），要么全丢。</p>

<h2 id="五-错误场景测试-确保报错信息精确">五、错误场景测试：确保报错信息精确</h2>

<p>除了“正常路径”，集成测试还覆盖“错误路径”——确保各类格式错误能正确报错，并且报错信息包含行号和区块名：</p>

<pre><code class="language-go">func TestParseErrors(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        wantErr string // 错误信息应包含的子串
    }{
        {
            name:    &quot;区块外有内容&quot;,
            input:   &quot;这是区块外的内容\n【角色名】\n贝利亚&quot;,
            wantErr: &quot;内容出现在任何区块之外&quot;,
        },
        {
            name:    &quot;列表项缺少 - 前缀&quot;,
            input:   &quot;【锚点】\n核心信念：力量&quot;,
            wantErr: &quot;列表项必须以 '-' 开头&quot;,
        },
        {
            name:    &quot;列表项缺少冒号&quot;,
            input:   &quot;【锚点】\n- 核心信念 \&quot;力量\&quot;&quot;,
            wantErr: &quot;缺少 ':' 或 '：'&quot;,
        },
        // ...
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            _, err := ParseString(tt.input)
            if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
                t.Errorf(&quot;期望错误包含 '%s'，实际: %v&quot;, tt.wantErr, err)
            }
        })
    }
}
</code></pre>

<p>这些测试确保报错信息不会退化为“unexpected token at position 42”——那是我们在第一篇就决定要消灭的东西。</p>

<h2 id="六-代价">六、代价</h2>

<ul>
<li>维护 Golden 文件需要手动确认（首次生成或更新时要检查内容是否正确）</li>
<li>错误场景测试需要覆盖尽可能多的边界情况</li>
<li>每次新增区块类型，需要同步更新测试用例</li>
</ul>

<p>但收益是：<strong>重构时可以放心改代码，只要测试全绿，行为就没变。</strong></p>

<h2 id="七-小结">七、小结</h2>

<p>集成测试是工程的“看门狗”。它把解析器的行为冻结下来，任何改动都必须经过验证。</p>

<p>四件事构成了这套测试体系：</p>

<ol>
<li><strong>Golden File 测试</strong>：固化标准契约的解析结果</li>
<li><strong>解析即验证</strong>：解析过程中直接检查必填项和完整性</li>
<li><strong>滑窗老化测试</strong>：确保历史记录按整轮截断</li>
<li><strong>错误场景测试</strong>：确保报错信息精确到行号</li>
</ol>

<p>有了这套体系，后续的引擎开发可以放心迭代——不怕改坏东西，测试会告诉你。</p>

<p>下一篇，我们将进入引擎的运行时。要解决的核心问题是：<strong>拿到了 <code>domain.Contract</code> 之后，怎么驱动大模型生成符合规则的叙事？</strong></p>

<p>答案是<strong>三明治 Prompt 结构</strong>——把格式约束放在上下两端，把上下文放在中间，彻底根除括号剧本流。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[解析器写完了，但怎么保证以后改代码不会改坏它？Golden File 测试、验证器、滑窗老化测试——用测试将解析行为彻底冻结。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：规则表达式与插值语法的解析]]></title>
      <link>https://moongate.top/docs/narrative-engine-parsing-rules-and-interpolation</link>
      <guid isPermaLink="true">https://moongate.top/docs/narrative-engine-parsing-rules-and-interpolation</guid>
      <pubDate>Mon, 20 Jul 2026 19:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-叙事引擎的第三个问题-条件-动作怎么拆解">一、叙事引擎的第三个问题：条件-动作怎么拆解？</h2>

<p>区块切分完以后，最复杂的部分是<strong>规则表达式</strong>。一个叙事引擎需要回答：条件怎么写、动作怎么写、变量怎么引用。</p>

<p>这里有一条重要的设计边界：<strong>解析器只负责拆解，不负责求值</strong>。条件字符串（如 <code>包含 &quot;攻击&quot; &amp;&amp; 状态.堕落指数 &gt; 80</code>）原样存入结构体，引擎运行时再计算 true/false。为什么这样做？因为求值所需的运行状态在解析时还不存在——状态值是在对话过程中动态变化的，无法在加载阶段就确定。</p>

<p>这一篇要解决的就是拆解逻辑本身。</p>

<hr>

<h2 id="二-规则解析-拆解条件-动作">二、规则解析：拆解条件-动作</h2>

<p>规则格式固定：<code>[规则名] if 条件 -&gt; 动作</code></p>

<pre><code class="language-meph">[攻击] if 包含 &quot;攻击&quot; -&gt; 注入 &quot;贝利亚发动了猛烈的攻击&quot;
[光之国] if 包含 &quot;光之国&quot; &amp;&amp; 状态.情绪 == &quot;暴怒&quot; -&gt; 注入 &quot;光之国的记忆让贝利亚更加愤怒&quot;
[高堕落] if 状态.堕落指数 &gt; 80 -&gt; 状态.情绪 = &quot;癫狂&quot;
</code></pre>

<p>插值语法出现在规则动作和文本区块中：</p>

<pre><code class="language-meph">注入 &quot;{角色名}的故乡是光之国&quot;
</code></pre>

<p>这一篇要解决两个问题：</p>

<ol>
<li><strong>规则的条件和动作怎么拆解成可存储的结构？</strong></li>
<li><strong>插值语法怎么在解析层被识别和处理？</strong></li>
</ol>

<h3 id="2-1-规则名提取">2.1 规则名提取</h3>

<p>找到第一个 <code>[</code> 和第一个 <code>]</code>，取中间内容：</p>

<pre><code class="language-go">func parseRuleLine(line string, lineNumber int) (*domain.Rule, error) {
    trimmed := strings.TrimSpace(line)
    if !strings.HasPrefix(trimmed, &quot;[&quot;) {
        return nil, fmt.Errorf(&quot;规则必须以 '[' 开头&quot;)
    }
    idx := strings.Index(trimmed, &quot;]&quot;)
    if idx == -1 {
        return nil, fmt.Errorf(&quot;缺少闭合的 ']'&quot;)
    }
    name := strings.TrimSpace(trimmed[1:idx])
    if name == &quot;&quot; {
        return nil, fmt.Errorf(&quot;规则名不能为空&quot;)
    }

    rest := strings.TrimSpace(trimmed[idx+1:])
    // 接下来提取条件和动作...
}
</code></pre>

<h3 id="2-2-条件与动作的拆分">2.2 条件与动作的拆分</h3>

<p>用 <code>if</code> 和 <code>-&gt;</code> 作为分隔符：</p>

<pre><code class="language-go">// 去掉 &quot;if &quot; 前缀
if !strings.HasPrefix(rest, &quot;if &quot;) {
    return nil, fmt.Errorf(&quot;规则条件必须以 'if ' 开头&quot;)
}
rest = strings.TrimPrefix(rest, &quot;if&quot;)
rest = strings.TrimSpace(rest)

// 取第一个 &quot;-&gt;&quot; 分割条件和动作
cond, action, ok := strings.Cut(rest, &quot;-&gt;&quot;)
if !ok {
    return nil, fmt.Errorf(&quot;规则缺少 '-&gt;'&quot;)
}
cond = strings.TrimSpace(cond)
action = strings.TrimSpace(action)
</code></pre>

<h3 id="2-3-互斥组">2.3 互斥组</h3>

<p>动作中可能带有 <code>[group:xxx]</code> 标记：</p>

<pre><code class="language-text">[攻击] if 包含 &quot;攻击&quot; -&gt; [group:combat] 注入 &quot;贝利亚发动了猛烈的攻击&quot;
</code></pre>

<p>解析时提取组名，存入 <code>domain.Rule.Group</code>：</p>

<pre><code class="language-go">group := &quot;&quot;
if strings.HasPrefix(action, &quot;[group:&quot;) {
    endIdx := strings.Index(action, &quot;]&quot;)
    if endIdx != -1 {
        group = action[7:endIdx]
        action = strings.TrimSpace(action[endIdx+1:])
    }
}
</code></pre>

<p>互斥组的作用是：同一组内多条规则，只有第一条匹配的会被触发。这个逻辑在引擎运行时生效，解析层只需存好组名。</p>

<p>互斥组可以搭配任何动作类型，不仅限于 <code>注入</code>：</p>

<pre><code class="language-text">[高堕落] if 状态.堕落指数 &gt; 80 -&gt; [group:escalate] 状态.情绪 = &quot;癫狂&quot;
[失控] if 状态.情绪 == &quot;癫狂&quot; &amp;&amp; 状态.堕落指数 &gt; 90 -&gt; [group:escalate] 注入 &quot;{角色名}已完全失控&quot;
</code></pre>

<h3 id="2-4-引号处理">2.4 引号处理</h3>

<p>条件和动作中的字符串用 <code>&quot;</code> 包裹。解析时我调用 <code>unquote</code> 剥离外层引号：</p>

<pre><code class="language-go">func unquote(s string) (string, error) {
    s = strings.TrimSpace(s)
    if len(s) &gt;= 2 {
        if (strings.HasPrefix(s, &quot;\&quot;&quot;) &amp;&amp; strings.HasSuffix(s, &quot;\&quot;&quot;)) ||
           (strings.HasPrefix(s, &quot;“&quot;) &amp;&amp; strings.HasSuffix(s, &quot;”&quot;)) {
            return s[1:len(s)-1], nil
        }
    }
    return s, nil
}
</code></pre>

<p>支持中文引号是因为创作者可能使用中文输入法，<code>“</code> 和 <code>”</code> 比 <code>&quot;</code> 更容易自然输入。</p>

<h2 id="三-插值语法-变量名-的识别与替换">三、插值语法：{变量名} 的识别与替换</h2>

<p>插值的核心需求：<strong>在任意文本中识别 <code>{角色名}</code> 并替换为对应的角色名称。</strong> 当前支持的插值变量只有一个——<code>{角色名}</code>，它来自 <code>【角色名】</code> 区块，静态不变。</p>

<h3 id="3-1-为什么只有-角色名">3.1 为什么只有 <code>{角色名}</code>？</h3>

<p>你可能好奇：为什么状态变量（如堕落指数、情绪）不能直接用 <code>{堕落指数}</code> 插值？</p>

<p>因为状态变量在引擎中是<strong>运行时动态读写</strong>的——创作者通过 <code>状态.键 = 值</code> 来修改，通过 <code>状态.键 &gt; 值</code> 来比较。如果允许 <code>{堕落指数}</code> 插值，就引入了一套平行机制：既可以在规则中通过 <code>状态.堕落指数</code> 引用它，又可以通过 <code>{堕落指数}</code> 引用它。两套语法做同一件事，徒增学习成本。</p>

<p>所以状态变量的引用统一使用 <code>状态.键</code> 语法，不设插值。引擎运行时的模板替换也只做一件事：把 <code>{角色名}</code> 替换为实际的角色名称。</p>

<h3 id="3-2-替换时机-三个场景">3.2 替换时机：三个场景</h3>

<p>插值替换在引擎中发生在<strong>三个不同场景</strong>：</p>

<ol>
<li><p><strong>CLI 欢迎界面显示时</strong>：世界观、开局场景在显示给人类看时替换一次。创作者看到的是&rdquo;贝利亚奥特曼&rdquo;而非 <code>{角色名}</code>。</p></li>

<li><p><strong>规则动作执行时</strong>：<code>注入 &quot;{角色名}的故乡是光之国&quot;</code> 在每次规则触发时执行替换。这是插值最核心的使用场景。</p></li>

<li><p><strong>Prompt 构建中</strong>：世界观和背景文本<strong>原样传给 LLM</strong>，不依赖引擎主动替换。LLM 从上下文中（&rdquo;你是贝利亚奥特曼&rdquo;）自然理解 <code>{角色名}</code> 的含义。</p></li>
</ol>

<h4 id="为什么第三种场景不主动替换">为什么第三种场景不主动替换？</h4>

<p>因为替换会改变文本的原始形态。如果将 <code>{角色名}的故乡是光之国</code> 替换为 <code>贝利亚奥特曼的故乡是光之国</code>，LLM 收到的是一段已经&rdquo;固化&rdquo;的叙述。而保持 <code>{角色名}</code> 原样，让 LLM 在生成时根据上下文动态决定如何使用这个名字——在某些叙事分支中，角色名可能发生变化（如角色改名、被遗忘等），这时保留占位符反而更灵活。</p>

<h2 id="四-交汇点-当规则遇上插值">四、交汇点：当规则遇上插值</h2>

<p>以这条规则为例：</p>

<pre><code class="language-meph">[光之国] if 包含 &quot;光之国&quot; -&gt; 注入 &quot;{角色名}的故乡是光之国&quot;
</code></pre>

<p>完整执行流程：</p>

<pre><code class="language-text">用户输入 &quot;我要去光之国！&quot;
    │
    ▼
规则匹配：包含 &quot;光之国&quot; → true
    │
    ▼
动作识别：提取动作类型为 &quot;注入&quot;
    │
    ▼
运行时替换：{角色名} → &quot;贝利亚奥特曼&quot;
    │
    ▼
追加记忆：&quot;贝利亚奥特曼的故乡是光之国&quot; 写入记忆库
    │
    ▼
LLM 叙事：带着新记忆生成响应
</code></pre>

<h3 id="为什么坚持把替换放在运行时">为什么坚持把替换放在运行时？</h3>

<p>因为引擎支持多分支故事线。如果加载时就替换成静态文本，所有分支共享同一个值，无法独立演化。运行时替换意味着每个分支读取自己的状态——状态变了，插值结果就变了。</p>

<h3 id="骰子表达式的容错">骰子表达式的容错</h3>

<p>骰子表达式（如 <code>roll(1d100) &gt;= 80</code>）在解析时原样存储，运行时求值。如果表达式格式错误（如 <code>roll(1d10</code> 缺少括号），引擎不会报错，而是视为条件不满足（返回 <code>false</code>）。这是因为骰子表达式本身就是条件的一部分，解析层不负责验证运行时的正确性——格式错误就当作&rdquo;不匹配&rdquo;处理。</p>

<h2 id="五-小结-解析层完整了">五、小结：解析层完整了</h2>

<p>到这一篇为止，解析层覆盖了所有语法单元：</p>

<table>
<thead>
<tr>
<th>区块类型</th>
<th>解析函数</th>
<th>复杂度</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>文本区块</td>
<td><code>parseTextBlock</code></td>
<td>低</td>
<td>拼接内容，保留换行</td>
</tr>

<tr>
<td>键值对列表</td>
<td><code>parseKeyValuePairs</code></td>
<td>中</td>
<td>支持中英文冒号，精确报报错</td>
</tr>

<tr>
<td>规则列表</td>
<td><code>parseRules</code></td>
<td><strong>高</strong></td>
<td>条件表达式、互斥组、骰子表达式</td>
</tr>

<tr>
<td>纯文本列表</td>
<td><code>parsePlainList</code></td>
<td>低</td>
<td>逐行提取</td>
</tr>

<tr>
<td><strong>插值语法</strong></td>
<td><code>ReplacePlaceholders</code>（运行时）</td>
<td>中</td>
<td>解析时保留，运行时替换</td>
</tr>
</tbody>
</table>

<h3 id="最关键的一条边界">最关键的一条边界</h3>

<blockquote>
<p>解析器只负责&rdquo;读出来&rdquo;——把文本转化为结构化的数据。<br>
引擎负责&rdquo;算出来&rdquo;——条件求值、变量替换、动作执行。</p>
</blockquote>

<p>下一篇，我们将跨过这条边界，进入引擎的运行时。而引擎面对的第一个工程问题是：<strong>如何保证代码改动不破坏现有的解析行为？</strong></p>

<p>答案是<strong>集成测试</strong>——用一组固定的 <code>.meph</code> 契约作为&rdquo;看门狗&rdquo;，每次改动后对比解析结果是否与预期一致。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[规则的条件-动作表达式怎么拆解？{变量} 插值语法怎么处理？本文覆盖解析层最后两块拼图——让规则从文本变为可执行结构。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[DSL]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：区块扫描与行号绑定]]></title>
      <link>https://moongate.top/docs/narrative-engine-block-scanner-and-line-numbers</link>
      <guid isPermaLink="true">https://moongate.top/docs/narrative-engine-block-scanner-and-line-numbers</guid>
      <pubDate>Mon, 20 Jul 2026 17:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-叙事引擎的第二个问题-规则怎么变成结构">一、叙事引擎的第二个问题：规则怎么变成结构？</h2>

<p>格式定义好之后，下一个问题是<strong>解析</strong>——怎么把文本变成程序能操作的数据。</p>

<p>这里有两个关键指标：<strong>解析正确性</strong>和<strong>报错可读性</strong>。通用格式（JSON/YAML）在第一个指标上很好，但在第二个指标上几乎是灾难——<code>position 246</code> 对创作者毫无意义。而对于自定义格式，解析器必须从头实现。</p>

<p>一篇好的解析器设计，应该做到三件事：</p>

<ol>
<li><strong>精确识别</strong>：每个区块、每行内容都能被正确归类</li>
<li><strong>行号绑定</strong>：每一条错误都能追溯到具体行，而不是字符串偏移量</li>
<li><strong>白名单校验</strong>：错误的写法（如 <code>【脚色名】</code>）不会被当作有效内容</li>
</ol>

<p>这一篇实现的就是这三件事。</p>

<hr>

<h2 id="二-先看问题-如果没有行号绑定">二、先看问题：如果没有行号绑定</h2>

<p>在开始写解析器之前，先看一个真实的场景。</p>

<p>创作者写了一份契约，其中一行是：</p>

<pre><code class="language-meph">【锚点】
- 核心信念 &quot;力量就是一切&quot;
</code></pre>

<p>注意，<code>- 核心信念 &quot;力量就是一切&quot;</code> 缺少了冒号（正确写法是 <code>- 核心信念: &quot;力量就是一切&quot;</code>）。</p>

<p>如果我用通用解析器，报错大概是：</p>

<pre><code>unexpected token at position 42
</code></pre>

<p>创作者需要复制粘贴去数 position 42 是哪个字符。这个过程极其折磨。</p>

<p>而我的目标是让解析器报出这样的错误：</p>

<pre><code>第 2 行（区块「锚点」）：缺少 ':' 或 '：'
</code></pre>

<p>不需要数 position，不需要理解“token”是什么，直接告诉创作者：“第二行缺了一个冒号。”</p>

<p><strong>这个差异，就是手写解析器的全部理由。</strong></p>

<h2 id="三-两阶段设计">三、两阶段设计</h2>

<p>我把解析拆成两个阶段：</p>

<table>
<thead>
<tr>
<th>阶段</th>
<th>职责</th>
<th>输入</th>
<th>输出</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>区块扫描器</strong></td>
<td>切分区块，记录行号</td>
<td>原始文本</td>
<td><code>[]Block</code></td>
</tr>

<tr>
<td><strong>结构化解析器</strong>（Parser）</td>
<td>结构化解析</td>
<td><code>[]Block</code></td>
<td><code>*domain.Contract</code></td>
</tr>
</tbody>
</table>

<h3 id="关键设计">关键设计</h3>

<p>行号在扫描阶段就绑定到每一行，Parser 直接使用，无需计算偏移量。这样报错时永远是精确的绝对行号。</p>

<p>数据结构就两个：</p>

<pre><code class="language-go">type Line struct {
    Text   string
    Number int  // 绝对行号，从 1 开始
}

type Block struct {
    Title   string  // 如 &quot;角色名&quot;
    Content []Line  // 内容行，自带行号
    Line    int     // 标题行号
}
</code></pre>

<p>有了这个结构，报错可以这样写：</p>

<pre><code class="language-go">fmt.Errorf(&quot;第 %d 行（区块「%s」）：列表项必须以 '-' 开头&quot;,
    line.Number, blockName)
</code></pre>

<h2 id="四-区块扫描器-一个简单的状态机">四、区块扫描器：一个简单的状态机</h2>

<p>核心是一个逐行扫描的状态机。它只有两个状态：</p>

<ul>
<li><code>inBlock == false</code>：当前不在任何区块内</li>
<li><code>inBlock == true</code>：当前在区块内，正在收集内容</li>
</ul>

<pre><code class="language-go">func Lex(text string) ([]Block, error) {
    lines := strings.Split(text, &quot;\n&quot;)
    var blocks []Block
    var currentTitle string
    var currentContent []Line
    var currentLine int
    inBlock := false

    for i, rawLine := range lines {
        lineNumber := i + 1

        // 不在区块内时，空行跳过
        if !inBlock &amp;&amp; strings.TrimSpace(rawLine) == &quot;&quot; {
            continue
        }

        // 检查是否为区块标题
        if title, ok := isBlockTitle(rawLine); ok {
            if inBlock {
                // 保存当前区块
                blocks = append(blocks, Block{
                    Title:   currentTitle,
                    Content: currentContent,
                    Line:    currentLine,
                })
            }
            // 开始新区块
            currentTitle = title
            currentContent = []Line{}
            currentLine = lineNumber
            inBlock = true
            continue
        }

        // 非标题行：必须在区块内
        if !inBlock {
            return nil, fmt.Errorf(&quot;第 %d 行：内容出现在任何区块之外&quot;, lineNumber)
        }

        currentContent = append(currentContent, Line{
            Text:   rawLine,
            Number: lineNumber,
        })
    }

    if inBlock {
        blocks = append(blocks, Block{...})
    }

    if len(blocks) == 0 {
        return nil, fmt.Errorf(&quot;没有有效区块&quot;)
    }
    return blocks, nil
}
</code></pre>

<h3 id="两个关键设计">两个关键设计</h3>

<h3 id="1-白名单前置">1. 白名单前置</h3>

<p><code>isBlockTitle</code> 只认预定义的标题列表：</p>

<pre><code class="language-go">var knownBlocks = map[string]bool{
    &quot;角色名&quot;: true,
    &quot;锚点&quot;:  true,
    &quot;规则&quot;:  true,
    &quot;状态&quot;:  true,
    // ...
}
</code></pre>

<p>如果创作者写了 <code>【脚色名】</code>（错别字），扫描器不会把它当作区块开始——而是会得到一个指向该行的错误。具体报错信息取决于上下文：如果该行出现在任何区块之外，报错“内容出现在任何区块之外”；如果出现在某个区块内部，则会在该区块的解析中报错。无论哪种情况，行号都是精确的。</p>

<h3 id="2-行号绑定">2. 行号绑定</h3>

<p>每一行在存入时直接携带 <code>lineNumber</code>，永不偏移。这是实现精确报错的根基。</p>

<h2 id="五-parser-路由到不同解析函数">五、Parser：路由到不同解析函数</h2>

<p>扫描器输出 <code>[]Block</code> 后，Parser 根据 <code>Title</code> 路由到对应的解析函数：</p>

<pre><code class="language-go">func parseBlocks(blocks []Block) (*domain.Contract, error) {
    contract := &amp;domain.Contract{}
    for _, block := range blocks {
        switch block.Title {
        case &quot;角色名&quot;:
            contract.RoleName, err = parseRoleName(block.Content, block.Line)
        case &quot;锚点&quot;:
            contract.Anchor, err = parseKeyValuePairs(block.Content, block.Title)
        case &quot;规则&quot;:
            contract.Rules, err = parseRules(block.Content, block.Title)
        // ... 其他区块
        }
        if err != nil {
            return nil, err
        }
    }
    return contract, nil
}
</code></pre>

<p>每种区块的解析逻辑是独立的。以键值对列表为例——所有错误都带行号和区块名：</p>

<pre><code class="language-go">func parseKeyValuePairs(lines []Line, blockName string) ([]KeyValue, error) {
    for _, line := range lines {
        trimmed := strings.TrimSpace(line.Text)
        if trimmed == &quot;&quot; || strings.HasPrefix(trimmed, &quot;#&quot;) {
            continue
        }
        if !strings.HasPrefix(trimmed, &quot;-&quot;) {
            return nil, fmt.Errorf(&quot;第 %d 行（区块「%s」）：列表项必须以 '-' 开头&quot;,
                line.Number, blockName)
        }
        // ... 提取键值对
    }
    return result, nil
}
</code></pre>

<h2 id="六-错误信息对比">六、错误信息对比</h2>

<p>同一个错误，两种体验：</p>

<table>
<thead>
<tr>
<th>用户写错的内容</th>
<th>通用解析器报错</th>
<th><code>.meph</code> 报错</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>- 核心信念 &quot;力量&quot;</code>（缺冒号）</td>
<td><code>Unexpected token at position 42</code></td>
<td><code>第 2 行（区块「锚点」）：缺少 ':' 或 '：'</code></td>
</tr>

<tr>
<td><code>情绪: 暴怒</code>（缺 <code>-</code>）</td>
<td><code>invalid character looking for value</code></td>
<td><code>第 2 行（区块「状态」）：列表项必须以 '-' 开头</code></td>
</tr>

<tr>
<td><code>【脚色名】</code>（错别字）</td>
<td>不适用</td>
<td>指向该行的精确错误</td>
</tr>
</tbody>
</table>

<h2 id="七-代价">七、代价</h2>

<ul>
<li>写了约 400 行 Go 代码</li>
<li>需要为每个区块类型写独立的解析逻辑</li>
<li>新增区块要同步更新白名单</li>
</ul>

<p>但没有外部依赖，<code>go build</code> 一步完成。解析逻辑完全可控，可以随时调整。</p>

<h2 id="八-小结">八、小结</h2>

<p>区块扫描器和 Parser 完成了“文本到结构”的转化。现在 <code>【角色名】</code> 变成了 <code>contract.RoleName</code>，<code>【规则】</code> 变成了 <code>contract.Rules</code>。</p>

<p>但 <code>contract.Rules</code> 里的条件（如 <code>包含 &quot;攻击&quot;</code>）和动作（如 <code>注入 &quot;{角色名}的故乡是光之国&quot;</code>）仍然是字符串。下一篇我们要处理的是：<strong>规则的条件-动作表达式怎么拆解？插值语法 <code>{变量}</code> 怎么处理？</strong></p>

<p>这是解析层最后两块拼图。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[从格式设计到解析器实现，手写区块扫描器让错误报出"第 12 行"而非"position 246"——精确的行号绑定是手写解析器的核心价值。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[DSL]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：快速上手——从零写出你的第一个契约]]></title>
      <link>https://moongate.top/docs/narrative-engine-your-first-contract</link>
      <guid isPermaLink="true">https://moongate.top/docs/narrative-engine-your-first-contract</guid>
      <pubDate>Mon, 20 Jul 2026 15:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-准备工作">一、准备工作</h2>

<p>你需要三样东西：</p>

<ol>
<li><strong>Go 1.26+</strong>：打开终端，运行 <code>go version</code> 确认版本</li>
<li><strong>一个终端</strong>：任何操作系统都可以</li>
<li><strong>（可选）一个 LLM API Key</strong>：DeepSeek、OpenAI 或 Ollama 均可——如果没有也不影响，引擎在没有 LLM 时也能工作</li>
</ol>

<p>如果你有 API Key，在项目根目录创建一个 <code>.env</code> 文件：</p>

<pre><code class="language-bash">MEPHISTO_CLIENT=openai
MEPHISTO_MODEL=deepseek-v4-flash
OPENAI_API_KEY=sk-你的密钥
</code></pre>

<h2 id="二-克隆并构建">二、克隆并构建</h2>

<pre><code class="language-bash">git clone https://github.com/yuelinghuashu/mephisto.git
cd mephisto
go build -o ./mephisto ./cmd/mephisto
</code></pre>

<p>如果一切顺利，你会看到一个名为 <code>mephisto</code> 的可执行文件。</p>

<h2 id="三-从零开始写契约">三、从零开始写契约</h2>

<p>接下来才是关键。我们创建一个新文件 <code>data/faust.meph</code>，从头开始填充内容。</p>

<h3 id="3-1-角色名">3.1 角色名</h3>

<p>契约的第一行是角色名。用 <code>【角色名】</code> 标记区块，下面直接写名字：</p>

<pre><code class="language-text">【角色名】
浮士德
</code></pre>

<p>就这么简单——不需要引号，不需要冒号，不需要任何标记。</p>

<h3 id="3-2-锚点">3.2 锚点</h3>

<p><code>【锚点】</code> 是角色的核心人格设定，用 <code>- 键: 值</code> 的格式列出：</p>

<pre><code class="language-text">【锚点】
- 核心信念：知识高于一切，我愿意为真理付出任何代价
- 欲望：体验一切人类能体验的事物
- 绝对禁忌：不会承认自己后悔
</code></pre>

<p>这些内容会被直接注入 LLM 的上下文，成为角色行为的基石。</p>

<h3 id="3-3-状态">3.3 状态</h3>

<p><code>【状态】</code> 是角色的动态变量，同样用键值对格式：</p>

<pre><code class="language-text">【状态】
- 灵魂完整度：100
- 情绪：永不满足
- 位置：书斋
</code></pre>

<p>状态值支持数字、布尔值、字符串三种类型。引擎在解析时自动推断类型——<code>&quot;100&quot;</code> 被解析为数字 <code>100</code>，<code>&quot;永不满足&quot;</code> 保持为字符串。这意味着你可以在规则中用 <code>状态.灵魂完整度 &gt; 50</code> 直接比较数字，无需额外类型转换。</p>

<p>在运行时，状态被存储为 <code>map[string]any</code>，以便快速读写。初始顺序从契约中继承，但运行时只按键名访问，不依赖顺序。</p>

<h3 id="3-4-世界观">3.4 世界观</h3>

<p><code>【世界观】</code> 是多行文本，直接写即可，保留换行：</p>

<pre><code class="language-text">【世界观】
故事发生在 16 世纪的德意志，这是一个神学与科学交织的时代。
大学中充斥着经院哲学的空洞争论，真正的新知被压制。
世界由上帝、天使、魔鬼与凡人共同构成，地狱与天堂是真实存在的维度。
梅菲斯特是地狱的使者，擅长以言语和契约诱捕人类灵魂。
灵魂交易在此世界是真实的契约——任何灵魂契约一旦签订，绝无撤回的可能。
</code></pre>

<h3 id="3-5-角色背景">3.5 角色背景</h3>

<p><code>【角色背景】</code> 与 <code>【世界观】</code> 一起定义了角色的详细背景。</p>

<pre><code class="language-text">【角色背景】
浮士德是一位学识渊博的学者，精通哲学、医学、法学和神学。
但他对人类知识的极限感到绝望——穷尽一生所学，仍无法触及世界的本质。
绝望中，他与梅菲斯特签订了契约：用灵魂换取在世上的无限体验。
</code></pre>

<h3 id="3-6-开局场景">3.6 开局场景</h3>

<p><code>【开局场景】</code> 定义了对话开始时的情境：</p>

<pre><code class="language-text">【开局场景】
深夜。书斋中烛火摇曳，桌上的书籍堆积成山。
浮士德站在窗边，望着窗外的月光。
桌角放着一份契约书，墨迹还未干透。
</code></pre>

<h3 id="3-7-规则-让角色活起来的关键">3.7 规则——让角色活起来的关键</h3>

<p><code>【规则】</code> 是引擎的核心。每条规则由三部分组成：<code>[规则名] if 条件 -&gt; 动作</code>。</p>

<pre><code class="language-text">【规则】
[新体验] if 包含 &quot;追求&quot; || 包含 &quot;想要&quot; || 包含 &quot;体验&quot; -&gt; 注入 &quot;{角色名}感到心中燃起新的渴望，没有什么能阻止他去亲自体验这一切&quot;
[梅菲斯特] if 包含 &quot;梅菲斯特&quot; || 包含 &quot;契约&quot; -&gt; 注入 &quot;梅菲斯特的声音在{角色名}耳边低语：'这就是你想要的吗？代价你可想好了。'&quot;
[灵魂代价] if 包含 &quot;代价&quot; || 包含 &quot;灵魂&quot; -&gt; 注入 &quot;{角色名}低头看着自己的双手，仿佛能看见什么东西正在一丝丝流逝&quot;
[永不满足] if 不包含 &quot;放弃&quot; &amp;&amp; 不包含 &quot;满足&quot; -&gt; 注入 &quot;{角色名}的眼中闪烁着永不熄灭的火焰，他还想要更多&quot;
</code></pre>

<p>规则的条件支持逻辑运算符（<code>&amp;&amp;</code>、<code>||</code>）、状态比较（<code>状态.键 &gt; 值</code>）、以及骰子表达式（<code>roll(1d100)</code>）。动作最常用的是 <code>注入</code>——将消息追加到记忆，由 LLM 自然融入后续叙事。</p>

<h3 id="3-8-验证">3.8 验证</h3>

<p>保存文件后，先验证解析是否正确：</p>

<pre><code class="language-bash">./mephisto parse data/faust.meph
</code></pre>

<p>你会看到类似这样的 JSON 输出：</p>

<pre><code class="language-json">{
  &quot;role_name&quot;: &quot;浮士德&quot;,
  &quot;anchor&quot;: [...],
  &quot;state&quot;: [...],
  &quot;rules&quot;: [...]
}
</code></pre>

<p>如果解析失败，错误信息会精确告诉你哪一行出了问题——比如&rdquo;第 X 行（区块「锚点」）：缺少 &lsquo;:&rsquo; 或 &lsquo;：&rsquo;&ldquo;。</p>

<h2 id="四-第一次对话-无-llm-模式">四、第一次对话（无 LLM 模式）</h2>

<p>即使没有配置 LLM，引擎也能运行：</p>

<pre><code class="language-bash">./mephisto run data/faust.meph
</code></pre>

<p>你会看到欢迎信息，然后进入对话模式。输入 &ldquo;我想要体验爱情&rdquo;：</p>

<pre><code class="language-text">命运 &gt; 我想要体验爱情
浮士德 沉默地注视着命运。
</code></pre>

<p>因为没有 LLM，引擎返回了默认响应。但规则其实已经匹配了——输入了&rdquo;我想要&rdquo;，条件 <code>包含 &quot;想要&quot;</code> 为 true，规则 [新体验] 触发，注入被写入记忆。</p>

<p>输入 <code>/state</code> 查看当前状态：</p>

<pre><code class="language-text">当前状态：
  灵魂完整度: 100
  情绪: 永不满足
  位置: 书斋
</code></pre>

<p>输入 <code>/history</code> 查看对话历史——你输入的&rdquo;我想要体验爱情&rdquo;已经记录为命运指引。</p>

<h3 id="这就是引擎在无-llm-下的工作方式">这就是引擎在无 LLM 下的工作方式</h3>

<p>规则匹配、注入、状态管理——全部正常运转。LLM 只是最后的&rdquo;叙事输出层&rdquo;。</p>

<h2 id="五-打开-llm-世界">五、打开 LLM 世界</h2>

<p>现在加上 API Key，重新运行：</p>

<pre><code class="language-bash">./mephisto run data/faust.meph -debug
</code></pre>

<p>你会看到同样的欢迎信息，但这次带上了 <code>-debug</code> 参数。LLM 的配置信息会先显示出来。</p>

<p>然后在提示符后输入：</p>

<pre><code class="language-text">命运 &gt; 你想要获取超越人类认知的知识
</code></pre>

<p>终端中出现了调试输出，然后是——梅菲斯特从阴影中走了出来：</p>

<pre><code class="language-text">🔍 规则调试模式
----------------------------------------
📌 检查规则 [新体验] (行 26)
   条件: 包含 &quot;追求&quot; || 包含 &quot;想要&quot; || 包含 &quot;体验&quot;
   结果: true
   ✅ 触发 → 注入 &quot;{角色名}感到心中燃起新的渴望，没有...

　　书斋里的烛火摇曳，将满墙的羊皮卷和典籍映照出深浅不一的阴影。浮士德枯坐在堆满手稿的书桌前，指尖摩挲着一本破旧星象书的封面，目光却穿过窗棂，望向一片漆黑的夜空。他低声自语，声音沙哑得几乎被风吹散：&quot;追寻了一生，终究连一扇门也未曾推开。&quot;

　　这时，身后的书架间传来一声极轻的响动，像老鼠啃咬木屑，又像一声压抑的嗤笑。浮士德并未回头，只是冷冷问道：&quot;又是你吗，瓦格纳？夜已经深了，不必再来送热汤。&quot;

　　脚步声却很轻，轻得不像那个笨拙的弟子。一个低沉的、带着金属质感的声音从暗处响起：&quot;瓦格纳只配为你添柴烧水，我带来的，是另一种暖意。&quot;说话者从阴影中缓步走出——他穿着华丽的猩红长袍，面容清瘦，嘴角挂着似笑非笑的弧度，手中把玩着一枚古铜色的戒指。他站定在书桌前，微微倾身，目光直刺浮士德的双瞳：&quot;你方才说，穷尽一生也推不开那扇门。可你有没有想过，门根本不是用来推的？&quot;

　　浮士德缓缓抬起头，盯住这位不速之客。他的手指按住那本星象书，沉声道：&quot;你是何人？未经允准，擅入我的书斋。&quot;

　　那人轻轻一笑，将戒指在烛光下转了转，戒指竟投出一片扭曲的影子，仿佛是某种无法言说的符文。&quot;我是你所有问题的答案，也是你所有渴望的代价。&quot;他伸出一只手，掌心向上，五指张开，掌纹中隐约流转着暗红色的光，&quot;我可以让你看见星辰背后的纹路，可以让你听见创世之初的旋律，可以让你触碰法则本身。只要你允许我带走一件微不足道的东西。&quot;

　　浮士德站起身，衣袖拂过桌面上散落的草稿纸，那些写满公式和推演的纸张飘落一地。他盯着那只递来的手，沉默了许久，才开口道：&quot;你要什么？&quot;

　　那人弯起嘴角，声音轻柔得像羽毛划过刀锋：&quot;你的灵魂。不过请放心，那东西你平日里也用不上——它既不能帮你解开方程，也不能让你飞上苍穹。你留着它，不过是让日渐腐朽的肉体多一块赘肉罢了。&quot;他收回手，转而从怀中取出一卷漆黑的羊皮纸，摊开在桌面上。纸面上没有字迹，只有一片深不见底的暗色，仿佛能吞噬周围的光线。&quot;签下它，我便立即兑现一切。&quot;

　　浮士德的呼吸变得急促，他低头看着那片漆黑，又抬头看向那人的眼睛——那双眼睛里倒映着无数星辰的陨落与诞生。他终于伸出手，指尖触到羊皮纸的瞬间，一股凉意顺着指骨爬上肩头。他没有再犹豫，接过那人递来的羽毛笔，笔尖刺破了自己的拇指，带着血珠落向纸面。

　　就在此时，书斋的门被推开一道缝，瓦格纳捧着一盏昏黄的油灯探进头来。他看到房中多了一个陌生人，又看到老师指尖渗血的姿态，面色顿时发白，颤抖着喊道：&quot;老师！您在做什么？此人是何时进来的？&quot;

　　浮士德没有停下动作，血字已在羊皮纸上成型。他头也不回地说：&quot;瓦格纳，关上门，今夜你将见证一位学者的夙愿。&quot;话音未落，羊皮纸上的暗色开始涌动，如同一片无星之夜在室内铺展开来，而那位红衣人的笑声，在书卷间回荡不绝。
</code></pre>

<p>这就是你写的 30 行契约生成的叙事。</p>

<hr>

<p><strong>试试改一个数字。</strong></p>

<p>把 <code>【状态】</code> 中的灵魂完整度从 <code>100</code> 改为 <code>50</code>，保存文件，重新运行，输入同样的话：</p>

<pre><code class="language-text">命运 &gt; 你想要获取超越人类认知的知识
</code></pre>

<p>同样的规则，同样的输入，只差一个数字，结果却截然不同：</p>

<pre><code class="language-text">　　书斋的烛火在深夜中摇曳不定，投下巨大的阴影，仿佛活物般在书架上爬行。浮士德瘫坐在堆满古籍的书桌后，指间夹着一根鹅毛笔，墨水早已干涸在笔尖。他的目光空洞地扫过那些拉丁文、希腊文与希伯来文的卷轴——这些曾经让他心醉神迷的符号，如今像一堆干枯的昆虫尸体般毫无意义。

　　“四十年。”他的声音沙哑得几乎听不清，“四十年钻研，我连第一重天的边缘都未曾触碰。”

　　窗外传来低沉的夜鸟鸣叫，像是某个东西在回应他的绝望。就在这时，书房的门无声无息地打开了，走进来的是他的老仆人瓦格纳，手里端着一碗热气腾腾的草药汤。瓦格纳小心翼翼地绕过地上堆叠的羊皮纸，将汤碗放在桌角，颇有些忧虑地开了口：“老爷，您已经三天没有进食了。帕拉塞尔苏斯的笔记您看了一整夜，眼睛血丝密布，再这样熬下去，怕是……”

　　“怕是什么？怕我这副躯体先于我的渴望腐烂？”浮士德猛地抬起头，眼中闪烁着某种近乎疯狂的光芒，“瓦格纳，你钻研学问多年，告诉我，你可曾见过真理的模样？”

　　瓦格纳被他盯得后退一步，低声答道：“老爷，仆以为，真理是上帝的事。”

　　“上帝的事。”浮士德重复着这几个字，忽然大笑起来，笑声中满是苦涩，“正是如此，正是如此。我们把一切都推给上帝，用神学的栏杆把自己圈在安全的猪圈里，啃食着神父们丢下来的干面包，还感激涕零！”他站起身，带倒了身后的椅子，烛火被他袍角带起的风压得几乎熄灭。“我受够了这些文字、这些符号、这些由人写出来骗人的东西。我需要真正的知识——那古老者、原初者才配拥有的知识。”

　　瓦格纳惊恐地看着他，双手微微发抖：“老爷，您在说什么？这样的念头是亵渎……”

　　“亵渎？”浮士德走到窗前，一把推开窗户，深夜的寒风呼啸着灌进来，吹得他灰色的长发和长袍疯狂翻卷，“若求真知即是亵渎，那就让这场亵渎来得更彻底些。”他回身，目光如燃烧的煤炭般炽热，“瓦格纳，你走吧。今夜我要独自一人。”

　　瓦格纳张了张嘴，最终还是低下头，飞快地退了出去，木门在他身后重重合上。浮士德独自站在打开的窗前，仰望头顶那片缀满星斗却沉默不语的夜空，喃喃道：“你们站在那里已有亿万年，难道就没有一句想对我这个区区凡人说的话吗？”

　　话音未落，书桌上的烛火猛地窜高，化作一片幽蓝色的火焰。火焰中央，一个声音低沉而优雅地响了起来——不是从空气中传来，而是仿佛直接在浮士德的颅骨里回荡。

　　“你终于愿意听了，浮士德先生。”那个声音带着笑意，“那么，就请允许我做一下自我介绍。”
</code></pre>

<h3 id="注意对比">注意对比</h3>

<p>灵魂完整度 100 时，浮士德是“枯坐的学者”，面对梅菲斯特的出场保持着冷静和犹疑——“你是谁？未经允准擅入我的书斋。”而灵魂完整度 50 时，他变成了“自焚边缘的疯狂求知者”，对瓦格纳怒吼，将知识追求定义为亵渎，连梅菲斯特出场的方式都更加激烈——不是从阴影中走出，而是烛火窜升、声音直接在颅骨内回荡。</p>

<p>状态值变了，叙事质感也跟着变了。<strong>这就是状态驱动的力量。</strong></p>

<hr>

<p>注意看第一次输出中的几个元素：</p>

<ul>
<li><strong>浮士德的学者身份和求知欲</strong>——来自 <code>【锚点】</code> 中的&rdquo;知识高于一切&rdquo;和 <code>【世界观】</code> 中&rdquo;穷尽一生也无法触及本质&rdquo;的设定。LLM 忠实地继承了这些特质。</li>
<li><strong>梅菲斯特的出现</strong>——由规则 [梅菲斯特] 在之前的对话中注入的记忆触发。你输入了&rdquo;想要获取超越人类认知的知识&rdquo;，规则条件 <code>包含 &quot;想要&quot;</code> 匹配，触发了 [新体验] 规则，注入的记忆为 LLM 提供了&rdquo;浮士德心中燃起新渴望&rdquo;的上下文。</li>
<li><strong>&ldquo;代价&rdquo;和&rdquo;灵魂&rdquo;的回应</strong>——LLM 在叙事中自然引入了&rdquo;代价&rdquo;这个关键词，触发了规则 [灵魂代价]——即使你没有在输入中直接说出这两个词。</li>
<li><strong>&ldquo;永不满足&rdquo;的底色</strong>——规则 [永不满足] 的条件是 <code>不包含 &quot;放弃&quot; &amp;&amp; 不包含 &quot;满足&quot;</code>，它几乎在每一轮都会触发，持续注入&rdquo;浮士德还想要更多&rdquo;的信息，让你写下的不只是单次对话，而是贯穿始终的角色气质。</li>
</ul>

<p><strong>每一行规则都在发挥作用。</strong> 没有一个多余。</p>

<h2 id="六-试试改点什么">六、试试改点什么</h2>

<p>现在你已经看到引擎的效果，可以动手修改看看变化：</p>

<h3 id="修改状态值">修改状态值</h3>

<pre><code class="language-text">- 灵魂完整度：50
</code></pre>

<p>你刚才已经看到了结果。试试改成 <code>10</code> 或 <code>0</code>，看看浮士德会变成什么样子。</p>

<h3 id="加一条骰子规则">加一条骰子规则</h3>

<pre><code class="language-text">[命运的眷顾] if 包含 &quot;追求&quot; &amp;&amp; roll(1d100) &gt;= 80 -&gt; 注入 &quot;命运似乎站在{角色名}这边，事情比预想中顺利&quot;
</code></pre>

<p><code>roll(1d100) &gt;= 80</code> 的意思是：掷一个 100 面骰，结果 &gt;= 80 时才触发。20% 的成功率，不是每次&rdquo;追求&rdquo;都会幸运——这为故事注入了真正的随机性。</p>

<h3 id="跑两轮对话-看子版存档">跑两轮对话，看子版存档</h3>

<p>运行两次对话后，用文本编辑器打开 <code>data/faust_child.meph</code>：</p>

<pre><code class="language-text">【状态】
- 灵魂完整度：100
- 情绪：永不满足

【记忆】
- 浮士德感到心中燃起新的渴望...
- 梅菲斯特的声音在他耳边低语...

【历史】
- fate: 我想要体验爱情
- assistant: ...
</code></pre>

<p>这就是引擎的 Mother-Child 存档机制：母版 <code>faust.meph</code> 是只读的静态契约，子版 <code>faust_child.meph</code> 是包含运行时状态、记忆和历史的动态快照。每次对话结束后自动保存。</p>

<h2 id="七-小结">七、小结</h2>

<p>到这里你完成了三件事：</p>

<ol>
<li><strong>写了一份完整的契约文件</strong>——30 行，覆盖了角色名、锚点、状态、世界观、规则等核心区块</li>
<li><strong>用解析器验证了它的结构</strong>——<code>parse</code> 命令精确告诉你内容是否正确</li>
<li><strong>运行引擎看到了角色&rdquo;活&rdquo;过来</strong>——即使没有 LLM，规则也在运行；有了 LLM，你写下的每条规则都在塑造叙事方向</li>
</ol>

<p>那 30 行是你和引擎之间的契约。引擎确保 LLM 遵守它。</p>

<hr>

<p>下篇文章将深入引擎内部，回答一个关键问题：<strong>区块扫描器是如何精确识别 <code>【角色名】</code> 和 <code>【规则】</code> 的？错误信息为什么能精确报出&rdquo;第 12 行（区块「锚点」）：缺少 &lsquo;:&rsquo; 或 &lsquo;：&rsquo;&ldquo;——而不是 <code>unexpected token at position 246</code>？</strong></p>

<p>答案是手写区块扫描器——一个逐行扫描、精确绑定行号、白名单前置的轻量级 Lexer。我们下一篇见。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[从零开始写一份 .meph 契约文件，编译、运行，让 LLM 遵守你写下的规则生成叙事。无需前置知识，快速完整体验。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[DSL]]></category>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[构建大模型叙事引擎：从自由叙事到契约约束]]></title>
      <link>https://moongate.top/docs/narrative-engine-from-freeform-to-constrained</link>
      <guid isPermaLink="true">https://moongate.top/docs/narrative-engine-from-freeform-to-constrained</guid>
      <pubDate>Mon, 20 Jul 2026 13:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="二-叙事引擎的第一个问题-规则怎么表达">二、叙事引擎的第一个问题：规则怎么表达？</h2>

<p>一个叙事引擎首先要解决的，是<strong>规则表达层</strong>的问题——创作者怎么告诉引擎&rdquo;这个角色会做什么、不会做什么&rdquo;。</p>

<p>通行的做法有三种：</p>

<ul>
<li><strong>通用结构化格式</strong>（JSON/YAML）：程序友好，但对创作者残忍——转义地狱、报错不可读</li>
<li><strong>自定义领域格式</strong>（如 .meph、Ink）：可以在两者之间找到平衡，但解析器必须手写</li>
<li><strong>纯文本</strong>：对创作者最友好，但程序几乎无法精确解析</li>
</ul>

<p>每种都有代价。我将一一对比，最后解释为什么选择了自定义格式。</p>

<hr>

<h2 id="三-先看目标-一份真实的-meph-契约">三、先看目标：一份真实的 .meph 契约</h2>

<p>在讨论任何设计之前，先看成品。下面是一份完整的 <code>.meph</code> 契约文件，来自项目中的 <code>data/sample.meph</code>：</p>

<pre><code class="language-meph">【角色名】
贝利亚奥特曼

【锚点】
- 核心信念：力量就是一切
- 说话风格：狂傲、嘲讽、不容置疑

【世界观】
光之国是宇宙中最强大的文明，也是贝利亚的故乡。
但他早已被驱逐，如今他带着对光之国的憎恨归来。

【状态】
- 堕落指数：50
- 情绪：暴怒
- 位置：宇宙空间站

【规则】
[攻击] if 包含 &quot;攻击&quot; -&gt; 注入 &quot;贝利亚发动了猛烈的攻击&quot;
[防御] if 包含 &quot;防御&quot; || 包含 &quot;防守&quot; -&gt; 注入 &quot;贝利亚摆出了防御姿态&quot;
[光之国] if 包含 &quot;光之国&quot; -&gt; 注入 &quot;{角色名}的故乡是光之国，也是他最大的仇恨来源&quot;
[高堕落] if 状态.堕落指数 &gt; 80 -&gt; 状态.情绪 = &quot;癫狂&quot;
</code></pre>

<p>这就是一份契约的全部。创作者看到的不是 <code>{</code> 和 <code>}</code>，不是转义引号，不是缩进层级，而是：</p>

<ul>
<li><code>【角色名】</code> 下面直接写名字</li>
<li><code>【锚点】</code> 下面用 <code>- 键: 值</code> 列出核心人格</li>
<li><code>【规则】</code> 下面用 <code>[名] if 条件 -&gt; 动作</code> 定义行为</li>
</ul>

<p>注意规则中的 <code>{角色名}</code>——这是<strong>插值占位符</strong>，解析器原样存储，运行时由引擎替换为当前角色名。这保证了多分支场景下各分支独立演化，互不干扰。</p>

<p>看起来像文档。但程序可以精确地解析它，并且当它出错时，能告诉创作者<strong>“第 12 行（区块「状态」）：列表项必须以 &lsquo;-&rsquo; 开头”</strong>，而不是 <code>unexpected token at position 246</code>。</p>

<p>接下来的问题就是：怎么走到这一步的？</p>

<h2 id="四-json-对程序友好-对人残忍">四、JSON：对程序友好，对人残忍</h2>

<p>JSON 是最自然的第一选择。结构清晰、解析简单、任何语言都有现成库。我最初也确实用 JSON 写过原型，但很快发现了一个致命问题。</p>

<p>上面那条规则在 JSON 里长这样：</p>

<pre><code class="language-json">{
  &quot;rules&quot;: [
    {
      &quot;name&quot;: &quot;攻击&quot;,
      &quot;condition&quot;: &quot;包含 \&quot;攻击\&quot;&quot;,
      &quot;action&quot;: &quot;注入 \&quot;贝利亚发动了猛烈的攻击\&quot;&quot;
    }
  ]
}
</code></pre>

<p>创作者的意图是：</p>

<pre><code class="language-text">包含 &quot;攻击&quot;
</code></pre>

<p>在 JSON 里必须写成：</p>

<pre><code class="language-text">&quot;condition&quot;: &quot;包含 \&quot;攻击\&quot;&quot;
</code></pre>

<p>问题不在于语法“有多难”，而在于<strong>心智切换成本</strong>。创作者在书写时不能直接表达自己的意图，必须时刻思考“我是在写 JSON 还是在写规则”。在大型契约中，这种成本是持续累积的——你阅读的不是内容，而是在不断核对“这一行有多少个反斜杠”。</p>

<p>更糟糕的是错误信息。一个常见的错误：在 <code>&quot;rules&quot;</code> 数组的最后一个元素后面多加了一个逗号。JSON 解析器报错：</p>

<pre><code class="language-text">Unexpected token } in JSON at position 246
</code></pre>

<p>创作者需要复制粘贴去数“position 246”在哪。这个体验对于非技术用户几乎是毁灭性的。</p>

<p><strong>JSON 对程序友好，但对人不友好。</strong> 而这份文件的作者是创作者，不是程序员。</p>

<h2 id="五-yaml-简单的幻觉">五、YAML：简单的幻觉</h2>

<p>YAML 看起来解决了 JSON 的可读性问题：</p>

<pre><code class="language-yaml">role_name: 贝利亚奥特曼
rules:
  - name: 攻击
    condition: 包含 &quot;攻击&quot;
    action: 注入 &quot;贝利亚发动了猛烈的攻击&quot;
</code></pre>

<p>缩进取代了括号和逗号，确实更像“写文档”了。</p>

<p>但 YAML 的问题比 JSON 更隐蔽。它不是“语法错误”的问题，而是“逻辑错误”的问题——文件能读，但行为完全不对。</p>

<p>想象一下：你在 YAML 中定义一个多行文本块（比如世界观），用 <code>|</code> 标记：</p>

<pre><code class="language-yaml">worldview: |
  光之国是宇宙中最强大的文明。
  贝利亚被驱逐后，一直在寻找复仇的机会。
</code></pre>

<p>然后在下面继续定义一个列表。缩进稍微偏差一个空格，解析器就可能把多行文本块的后续行当作列表的子元素。结果就是：<strong>世界观内容被截断，列表结构被破坏，没有任何报错，引擎运行时产生非预期的行为。</strong></p>

<p>更隐蔽的是，中文全角空格和英文半角空格在视觉上几乎无法分辨。创作者在编辑器中敲了一个全角空格（<code>　</code>）而非半角空格（），YAML 解析器不会把它当作缩进的一部分，而是抛出语法错误，或者诡异地将整段文本识别为键名。没有语法高亮的情况下，这种错误几乎无法肉眼排查。</p>

<p>创作者不是在阅读内容，而是在不断核对“这一行到底缩进了几个空格”。对于上百行的契约文件，这种认知负担是持续累积的。</p>

<p><strong>YAML 的“简单”是一个错觉。</strong> 它对人友好，但对程序来说，它的规则比 JSON 更复杂、更难以预测。</p>

<h2 id="六-纯文本-自由但模糊">六、纯文本：自由但模糊</h2>

<p>既然结构化格式都有问题，那能不能干脆不用结构，直接用纯文本写？</p>

<p>比如这样：</p>

<pre><code class="language-text">角色名是贝利亚奥特曼。
世界观是光之国。
规则是如果用户提到攻击，就执行攻击。
</code></pre>

<p>对创作者来说，这是最自然的方式——没有任何学习成本。</p>

<p>但问题是：程序看不懂。它不知道“角色名是贝利亚奥特曼”这句话里的“是”是声明还是叙述，不知道“世界观是光之国”和下一句“规则是……”之间是什么关系。纯文本的自然语言对人类来说是清晰的，但对程序来说，它是模糊的、歧义的、无法精确解析的。</p>

<p>如果我用纯文本，就需要设计一套隐式约定——比如靠关键词匹配来识别区块，靠换行来区分条目。这比显式语法更难保证正确性。</p>

<p><strong>纯文本对创作者最友好，但对程序几乎不友好。</strong></p>

<h2 id="七-三个方案的对比">七、三个方案的对比</h2>

<table>
<thead>
<tr>
<th>格式</th>
<th align="center">对创作者友好</th>
<th align="center">对程序友好</th>
<th>核心问题</th>
</tr>
</thead>

<tbody>
<tr>
<td>JSON</td>
<td align="center">❌</td>
<td align="center">✅</td>
<td>转义地狱、报错信息不可读</td>
</tr>

<tr>
<td>YAML</td>
<td align="center">⚠️</td>
<td align="center">✅</td>
<td>缩进敏感，多行文本与列表易混淆</td>
</tr>

<tr>
<td>纯文本</td>
<td align="center">✅</td>
<td align="center">❌</td>
<td>结构模糊，程序无法精确解析</td>
</tr>
</tbody>
</table>
<p>我需要一种格式：<strong>写起来像文档，读起来有结构。</strong> 它必须同时满足两个条件：</p>

<ol>
<li>创作者可以自然地书写，不需要学习 JSON 转义或 YAML 缩进规则</li>
<li>程序可以精确地解析，并且报错时能告诉创作者具体位置和原因</li>
</ol>

<p>这意味着我无法使用任何现成的通用格式——我需要设计一种专门针对“叙事契约”这个场景的格式。</p>

<h2 id="八-meph-的设计原则">八、.meph 的设计原则</h2>

<p>回到开篇那份 <code>.meph</code> 契约。它的设计基于三条原则：</p>

<h3 id="1-用人类语言做边界-消除括号恐惧">1. 用人类语言做边界，消除括号恐惧</h3>

<p>创作者看到的不是 <code>{</code> 和 <code>}</code>，而是 <code>【角色名】</code>。中文书名号对中文创作者来说比花括号自然得多。<code>【角色名】</code> 本身说明了区块的内容是什么，不需要额外注释。</p>

<p>更重要的是，区块标题被限定在白名单内（<code>角色名</code>、<code>锚点</code>、<code>规则</code>、<code>状态</code> 等）。如果创作者写了 <code>【脚色名】</code>（错别字），解析器不会把它当作区块开始——创作者会得到一个指向该行的错误，具体信息取决于上下文，但行号是精确的。</p>

<h3 id="2-区分-语义区块-而非-数据结构">2. 区分“语义区块”而非“数据结构”</h3>

<p>在 JSON 中，创作者需要自己决定用对象还是数组，这属于实现细节。在 <code>.meph</code> 中，创作者只需要知道“这是一个列表”或“这是一段话”。“角色名是单行文本”和“规则是列表”由解析器根据区块名识别，不由创作者声明。</p>

<h3 id="3-语法贴近自然逻辑">3. 语法贴近自然逻辑</h3>

<p>规则采用 <code>[规则名] if 条件 -&gt; 动作</code> 的直观写法。条件中的逻辑运算符用 <code>包含</code>、<code>状态.键 &gt; 值</code> 这类可读性强的表达，而不是纯符号。</p>

<h2 id="九-代价">九、代价</h2>

<p>这套设计不是没有代价的：</p>

<ul>
<li><strong>解析器需要手写</strong>：我不能用 <code>json.Unmarshal</code> 或 <code>yaml.Unmarshal</code>，需要自己写扫描器（Lexer）和解析器（Parser）</li>
<li><strong>需要维护白名单</strong>：新增区块时要同步更新 <code>knownBlocks</code> 列表</li>
<li><strong>需要文档</strong>：创作者需要学习这个格式的写法——虽然学习成本比 JSON 低，但毕竟需要学习</li>
<li><strong>工具链缺失</strong>：没有现成的语法高亮、格式化、校验工具</li>
</ul>

<p>但这个取舍的衡量标准很简单：<strong>这份文件的作者是谁？</strong></p>

<p>如果是程序员写、程序读，JSON 够用了。如果是创作者写、程序读，就需要一种“以人为中心”的格式。而叙事引擎的目标用户正是创作者——写故事的人。</p>

<p><strong>这个取舍，我认为是值得的。</strong></p>

<h2 id="十-小结">十、小结</h2>

<p>这篇文章回答了“用什么格式”这个问题。答案是 <code>.meph</code>——一种专门为叙事契约设计的、对创作者友好的文本格式。</p>

<p>但“设计”只解决了一半问题。下一篇，我们放下理论，先动手写一份真实的契约，看看它跑起来是什么样子。</p>

<blockquote>
<p>项目地址：<a href="https://github.com/yuelinghuashu/mephisto" target="_blank">https://github.com/yuelinghuashu/mephisto</a></p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[为什么 JSON 和 YAML 都不适合做叙事引擎的配置文件？本文从大模型叙事的一致性问题出发，解释了 .meph 格式的设计取舍——用解析器的复杂度换创作者体验。]]></description>
      <category><![CDATA[DSL]]></category>
      <category><![CDATA[JSON]]></category>
      <category><![CDATA[LLM]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:narrative-engine]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零实现词法分析器（三）：让指针动起来，启动 Lexer 传送带]]></title>
      <link>https://moongate.top/docs/from-text-to-token-go-lexer-part-3</link>
      <guid isPermaLink="true">https://moongate.top/docs/from-text-to-token-go-lexer-part-3</guid>
      <pubDate>Mon, 13 Jul 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-核心思想-lexer-就像一条传送带">一、核心思想：Lexer 就像一条传送带</h2>

<p>在动手写代码前，我们先要在脑海里建立一个物理模型。词法分析器（Lexer）的运作方式，极其类似于工厂里的<strong>扫描传送带</strong>：</p>

<ol>
<li><strong>输入文本</strong>被平铺在传送带上，每个字符（<code>rune</code>）占据一个格子。</li>
<li>有一个<strong>光标（指针）</strong>，指向当前正在观察的字符。</li>
<li>Lexer 的工作就是：</li>
</ol>

<ul>
<li>瞧一眼当前光标指着的字符是什么（<code>peek</code>）。</li>
<li>如果是一个符号（比如左括号 <code>【</code>），把它打包成 Token，然后光标往后挪一格（<code>advance</code>）。</li>
<li>如果是一串连续的普通文字（比如 <code>贝利亚奥特曼</code>），就把它们拼在一起打包成一个 <code>TEXT</code> Token，直到撞上符号再停下来。</li>
</ul>

<h2 id="二-定义-lexer-结构体">二、定义 Lexer 结构体</h2>

<p>在 <code>internal/parser/</code> 目录下创建 <code>lexer.go</code>。我们需要三个基础字段来维护这条“传送带”的状态：</p>

<pre><code class="language-go">package parser

// Lexer 词法分析器结构体
type Lexer struct {
	input    []rune // 待扫描的完整文本（转为 rune 切片，完美支持中文与 Emoji）
	position int    // 当前扫描到的字符索引位置
	line     int    // 当前行号（从 1 开始，用于后续报错提示）
}

// NewLexer 创建一个词法分析器实例
func NewLexer(input string) *Lexer {
	return &amp;Lexer{
		input:    []rune(input), // 将 string 转为 []rune 存储
		position: 0,
		line:     1,
	}
}
</code></pre>

<h3 id="为什么用-rune-input-提前转换">为什么用 <code>[]rune(input)</code> 提前转换？</h3>

<p>我们在上一篇强调过，Go 的 <code>string</code> 底层是字节流（<code>byte</code>）。如果每次读取都去算字节，处理中文会非常痛苦。我们在初始化时直接将它转为 <code>[]rune</code> 数组，之后光标的 <code>position++</code> 移动的每一个单位，就<strong>稳稳当当地代表一个独立的中文字符、英文字母或 Emoji</strong>。</p>

<h2 id="三-传送带的三驾马车-基础辅助方法">三、传送带的三驾马车：基础辅助方法</h2>

<p>为了操纵这个光标，我们需要实现三个最基本的辅助方法：检查结束、瞅一眼、往前移。</p>

<pre><code class="language-go">// isEOF 检查光标是否已经走到了文件末尾 (End of File)
func (l *Lexer) isEOF() bool {
	return l.position &gt;= len(l.input)
}

// peek 瞅一眼当前位置的字符，但【绝不挪动光标】
// 如果已经到末尾，返回 0
func (l *Lexer) peek() rune {
	if l.isEOF() {
		return 0
	}
	return l.input[l.position]
}

// advance 消费当前字符：读取并返回它，同时【将光标向后移动一位】
// 特别注意：如果消费的是换行符 '\n'，顺手将行号 line++
func (l *Lexer) advance() rune {
	if l.isEOF() {
		return 0
	}
	ch := l.input[l.position]
	if ch == '\n' {
		l.line++
	}
	l.position++
	return ch
}
</code></pre>

<p>有了这三个基础方法，我们的光标就能安全、自由地在文本数组中穿梭了。</p>

<h2 id="四-跳过无意义的空白">四、跳过无意义的空白</h2>

<blockquote>
<p>在 <code>.meph</code> 文件中，用户可能会在括号两边打上空格或缩进，比如 <code>【角色名】</code>。</p>

<p>这些空格没有实际语义，Lexer 应该在识别下一个 Token 之前，默默把它们吞掉。</p>
</blockquote>

<p>因为在同一个包（package parser）内部，我们直接越过复杂的函数封装，利用 Go 原生的 map 匹配来跳过空白：</p>

<pre><code class="language-go">// skipWhitespace 跳过无意义的空白字符（空格、制表符、回车符）
// 注意：千万不能跳过换行符 '\n'，因为换行在我们的语法里代表“区块标题结束”
func (l *Lexer) skipWhitespace() {
	for !l.isEOF() {
		info, ok := symbolMap[l.peek()]
		// 如果在符号表里，且 Category 是 whitespace，就消费掉它
		if !ok || info.Category != &quot;whitespace&quot; {
			break
		}
		l.advance() // 光标无情后移
	}
}
</code></pre>

<h2 id="五-核心逻辑-实现-nexttoken-分发器">五、核心逻辑：实现 <code>NextToken()</code> 分发器</h2>

<p>它的职责是：跳过空白，盯着光标位置。如果是符号表里的符号，一律直接消费并返回；如果表里找不到，那它必然是普通文本！</p>

<pre><code class="language-go">// NextToken 获取下一个 Token
func (l *Lexer) NextToken() Token {
	// 1. 每次进来，先清洗掉前面的无意义空格
	l.skipWhitespace()

	// 2. 查看当前光标指着谁
	ch := l.peek()

	// 3. 边界处理：如果到文件末尾了，吐出 EOF 哨兵 Token
	if ch == 0 {
		return Token{Type: TOKEN_EOF, Literal: &quot;&quot;, Line: l.line}
	}

	// 4. 🚀 终极查表驱动：因为在同包内，直接匹配 symbolMap！
	if info, ok := symbolMap[ch]; ok {
		l.advance() // 是符号？光标前进，消费它！
		return Token{Type: info.TokenType, Literal: string(ch), Line: l.line}
	}

	// 5. 符号表里查不到？那它一定是普通文本文字（如 &quot;贝利亚奥特曼&quot;）
	return l.readText()
}
</code></pre>

<p>你看！得益于同包内直接查表的设计，整个分发核心干净得让人感动。<strong>这里没有任何复杂的条件分支，更去掉了不必要的套娃函数调用</strong>。无论未来你的语法扩充到有多少种符号，这个 <code>NextToken()</code> 的大框架都<strong>稳如磐石，永远不需要修改一行。</strong></p>

<h2 id="六-文本的贪婪读取-readtext">六、文本的贪婪读取：<code>readText()</code></h2>

<p><code>NextToken()</code> 的核心逻辑我们已经看完了。现在进入它调用的最后一个分支——<code>readText()</code>，看看普通文本是如何被一口气吞下的。</p>

<p>当字符不在符号表里时（比如碰到了中文汉字 <code>贝</code>），Lexer 应该开启“贪婪模式”：<strong>只要后面接下来的字符不是符号，就一路把它们全部吞下，拼成一个长字符串。</strong></p>

<p>有了上一篇建立的统一符号表，判定“什么时候该停下来”也变得不可思议的优雅：<strong>只要 <code>peek()</code> 到的字符能在 <code>symbolMap</code> 里匹配成功，就说明撞到了符号（比如换行符或括号），它天然就是分隔符，文本读取立刻停止！</strong></p>

<pre><code class="language-go">// readText 读取一段连续的普通文本
// 停止条件：遇到符号表中的任意符号（如换行符、括号、冒号）或文件结束
func (l *Lexer) readText() Token {
	// 记录起始位置
	start := l.position

	for !l.isEOF() {
		// 🌟 降维打击：直接查表！只要当前字符在符号表里，说明撞墙了，立刻停下
		_, ok := symbolMap[l.peek()]
		if ok {
			break
		}
		l.advance() // 否则，继续快乐地吞噬文字
	}

	// 利用切片，把这段光标走过的 rune 范围直接转成字符串字面量
	literal := string(l.input[start:l.position])
	return Token{Type: TOKEN_TEXT, Literal: literal, Line: l.line}
}
</code></pre>

<h2 id="七-大功告成-在-main-go-中验证成果">七、大功告成：在 <code>main.go</code> 中验证成果</h2>

<p>让我们把现在的 <code>main.go</code> 升级一下，用我们亲手写的 <code>Lexer</code> 去解析测试文件，看看它能不能顺利吐出我们要的零件流：</p>

<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;mephisto/internal/parser&quot;
	&quot;os&quot;
)

func main() {
	if len(os.Args) &lt; 2 {
		fmt.Println(&quot;用法: mephisto &lt;文件&gt;&quot;)
		os.Exit(1)
	}

	content, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Printf(&quot;读取文件失败: %v\n&quot;, err)
		os.Exit(1)
	}

	// 1. 初始化我们的传送带 Lexer
	l := parser.NewLexer(string(content))

	// 2. 循环驱动传送带，直到撞上 TOKEN_EOF
	fmt.Printf(&quot;%-5s | %-20s | %s\n&quot;, &quot;行号&quot;, &quot;类型&quot;, &quot;字面量&quot;)
	fmt.Println(&quot;------+----------------------+-----------&quot;)
	for {
		tok := l.NextToken()
		// 为了防止换行符 \n 导致终端实际换行破坏表格，打印时做个转换处理
		literal := tok.Literal
		if tok.Type == parser.TOKEN_NEWLINE {
			literal = &quot;\\n&quot;
		}

		fmt.Printf(&quot;%-5d | %-20s | %s\n&quot;, tok.Line, tok.Type, literal)

		if tok.Type == parser.TOKEN_EOF {
			break
		}
	}
}
</code></pre>

<p>再次运行我们的测试文件（<code>testdata/sample.meph</code>）：</p>

<pre><code class="language-bash">go run main.go testdata/sample.meph
</code></pre>

<p>终端将会输出一行极其漂亮、对齐完美、极具工业美感的词法流结果：</p>

<pre><code class="language-text">行号    | 类型                 | 字面量
------+----------------------+-----------
1     | LEFT_BRACKET         | 【
1     | TEXT                 | 角色名
1     | RIGHT_BRACKET        | 】
1     | NEWLINE              | \n
2     | TEXT                 | 贝利亚奥特曼
3     | EOF                  |
</code></pre>

<p>看！计算机通过我们写的 Lexer，成功把一串冰冷的、毫无结构的原始字节，变成了一个个生动的、自带行号和类型的结构化 Token 块！</p>

<h2 id="八-小结">八、小结</h2>

<p>到这一篇为止，我们的 <strong>Lexer 词法分析器已经完全体诞生了</strong>！</p>

<table>
<thead>
<tr>
<th>做了什么</th>
<th>为什么</th>
</tr>
</thead>

<tbody>
<tr>
<td>设计了光标状态机（<code>position</code>）</td>
<td>建立了多字节文本遍历的底层传送带模型</td>
</tr>

<tr>
<td>实现了纯查表驱动的 <code>NextToken()</code></td>
<td>贯彻了数据驱动思想，去掉了多余函数，让符号分发效率达到极致</td>
</tr>

<tr>
<td>实现了极其精简的 <code>readText()</code> 截断机制</td>
<td>只要 peek 字符在 <code>symbolMap</code> 中即自动作为边界，消灭了所有零散的判断逻辑</td>
</tr>
</tbody>
</table>
<p>至此，词法分析的战役完美结束。</p>
]]></content:encoded>
      <description><![CDATA[正式实现词法分析器的核心引擎，通过光标指针的移动与符号表查询，将文本字符串切分成结构化的 Token 流。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Compiler]]></category>
      <dc:relation><![CDATA[series:lexer-from-scratch]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零实现词法分析器（二）：用一张表统一管理所有符号]]></title>
      <link>https://moongate.top/docs/from-text-to-token-go-lexer-part-2</link>
      <guid isPermaLink="true">https://moongate.top/docs/from-text-to-token-go-lexer-part-2</guid>
      <pubDate>Mon, 13 Jul 2026 21:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-回顾-上一篇我们停在了哪里">一、回顾：上一篇我们停在了哪里</h2>

<p>上一篇我们定义了 Token：</p>

<pre><code class="language-go">package parser

type TokenType string

const (
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;  // 【
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot; // 】
	TOKEN_TEXT          TokenType = &quot;TEXT&quot;          // 普通文本
)

type Token struct {
	Type    TokenType
	Literal string
}
</code></pre>

<p>现在我们需要一个程序——<strong>Lexer（词法分析器）</strong>——来读取文本，不断产出这些 Token。</p>

<p>但在写 Lexer 之前，我们还需要做一件事：<strong>扩充 Token 类型，并建立一套让 Lexer 能识别所有符号的机制。</strong></p>

<h2 id="二-扩充-token-类型">二、扩充 Token 类型</h2>

<p>上一篇我们只定义了三种 Token 类型。但在一个完整的 <code>.meph</code> 文件中，除了 <code>【</code>、<code>】</code> 和普通文本，还有更多符号需要识别：</p>

<table>
<thead>
<tr>
<th>符号</th>
<th>用途</th>
<th>示例</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>：</code> / <code>:</code></td>
<td>冒号</td>
<td><code>好感度：78</code></td>
</tr>

<tr>
<td><code>-</code></td>
<td>列表标记</td>
<td><code>- 核心信念：&quot;力量就是一切&quot;</code></td>
</tr>

<tr>
<td><code>@</code></td>
<td>引用符号</td>
<td><code>@[世界观](worlds/eva.meph)</code></td>
</tr>

<tr>
<td><code>#</code></td>
<td>标签/注释</td>
<td><code># 硬约束</code></td>
</tr>
</tbody>
</table>
<p><code>parser/token.go</code> 现在的完整代码如下：</p>

<pre><code class="language-go">package parser

type TokenType string

const (
	// 语法符号
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;  // 【
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot; // 】
	TOKEN_COLON         TokenType = &quot;COLON&quot;         // ：
	TOKEN_HYPHEN        TokenType = &quot;HYPHEN&quot;        // -
	TOKEN_AT            TokenType = &quot;AT&quot;            // @
	TOKEN_HASH          TokenType = &quot;HASH&quot;          // #

	// 内容类型
	TOKEN_TEXT TokenType = &quot;TEXT&quot; // 普通文本

	// 控制标记
	TOKEN_NEWLINE TokenType = &quot;NEWLINE&quot; // 换行符
	TOKEN_EOF     TokenType = &quot;EOF&quot;     // 文件结束（哨兵）
)

type Token struct {
	Type    TokenType
	Literal string    // 符号的原始文本
	Line    int       // 当前行号（从 1 开始）
}
</code></pre>

<p><strong>注意</strong>：</p>

<p><code>NEWLINE</code> 和 <code>EOF</code> 虽然在 Lexer 实现中才会用到，但我们提前定义好，这样后续文章就不需要回头修改 <code>token.go</code> 了。</p>

<h2 id="三-问题-lexer-需要认识多种符号">三、问题：Lexer 需要认识多种符号</h2>

<p>Lexer 的工作是扫描文本，识别出每个符号是什么。</p>

<p>一个最直接的写法是：</p>

<pre><code class="language-go">switch ch {
case '【':
    // 返回 LEFT_BRACKET
case '】':
    // 返回 RIGHT_BRACKET
case '：':
    // 返回 COLON
case '-':
    // 返回 HYPHEN
// ... 越来越多的 case
}
</code></pre>

<p>这样写有三个问题：</p>

<table>
<thead>
<tr>
<th>问题</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>新增符号要改代码</strong></td>
<td>每次增加新的符号类型，都要修改 Lexer 的源码</td>
</tr>

<tr>
<td><strong>中英双语支持困难</strong></td>
<td>如果要同时支持 <code>【</code> 和 <code>[</code> 作为左括号，<code>case</code> 会越来越多</td>
</tr>

<tr>
<td><strong>判断逻辑分散</strong></td>
<td>符号的&rdquo;定义&rdquo;和&rdquo;判断&rdquo;混在一起，不容易维护</td>
</tr>
</tbody>
</table>
<p><strong>我们需要一种更好的方式：把&rdquo;符号是什么&rdquo;和&rdquo;怎么判断符号&rdquo;分开。</strong></p>

<h2 id="四-方案-用一张表统一管理所有符号">四、方案：用一张表统一管理所有符号</h2>

<p>核心思想很简单：<strong>把所有的符号集中在一张表里管理，Lexer 通过查表来判断。</strong></p>

<p>这张表里，每个符号记录两件事：</p>

<ol>
<li><strong>对应的 Token 类型</strong>：如 <code>LEFT_BRACKET</code>、<code>RIGHT_BRACKET</code>、<code>COLON</code></li>
<li><strong>分类</strong>：如 <code>bracket</code>（括号）、<code>colon</code>（冒号）</li>
</ol>

<p>Lexer 只需要查这张表，就能知道当前字符是什么类型的 Token。</p>

<h2 id="五-关键决策-用-rune-而不是-byte">五、关键决策：用 <code>rune</code> 而不是 <code>byte</code></h2>

<p>在定义映射表之前，先确定一个关键问题：用什么类型来存储字符？</p>

<p>在 Go 中，遍历字符串有两种方式：</p>

<table>
<thead>
<tr>
<th>方式</th>
<th>类型</th>
<th>特点</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>byte</code></td>
<td>1 字节</td>
<td>只能处理 ASCII 字符（英文、数字、标点）</td>
</tr>

<tr>
<td><code>rune</code></td>
<td>4 字节</td>
<td>能处理任意 Unicode 字符（中文、Emoji）</td>
</tr>
</tbody>
</table>
<p><code>【</code> 在 UTF-8 中占 <strong>3 个字节</strong>。如果按 <code>byte</code> 遍历，<code>【</code> 会被拆成 3 个独立的字节，无法正确识别。</p>

<p>所以必须用 <code>rune</code>——它能保证每个字符（无论中英文）都被当作一个整体处理。</p>

<p><strong>用 <code>rune</code> 的代价：</strong> Go 的 <code>string</code> 底层是 <code>[]byte</code>，把它转成 <code>[]rune</code> 需要额外分配内存。但对于我们处理的 <code>.meph</code> 文件（通常只有几 KB 到几百 KB），这个代价可以忽略不计。<strong>正确性远比微小的性能损耗重要。</strong></p>

<h2 id="六-定义映射表">六、定义映射表</h2>

<h3 id="6-1-结构体定义">6.1 结构体定义</h3>

<p>创建 <code>parser/symbols.go</code>：</p>

<pre><code class="language-go">package parser

// SymbolInfo 符号信息
type SymbolInfo struct {
    TokenType TokenType // 对应的 Token 类型
    Category  string    // 分类：bracket, colon, hyphen, at, hash, whitespace, newline
}
</code></pre>

<h3 id="6-2-映射表">6.2 映射表</h3>

<pre><code class="language-go">// symbolMap 所有符号的映射表
// 新增符号只需在这里加一行
var symbolMap = map[rune]SymbolInfo{
    // 括号
    '【': {TokenType: TOKEN_LEFT_BRACKET, Category: &quot;bracket&quot;},
    '】': {TokenType: TOKEN_RIGHT_BRACKET, Category: &quot;bracket&quot;},
    '[':  {TokenType: TOKEN_LEFT_BRACKET, Category: &quot;bracket&quot;},
    ']':  {TokenType: TOKEN_RIGHT_BRACKET, Category: &quot;bracket&quot;},

    // 冒号
    '：': {TokenType: TOKEN_COLON, Category: &quot;colon&quot;},
    ':':  {TokenType: TOKEN_COLON, Category: &quot;colon&quot;},

    // 连字符
    '-':  {TokenType: TOKEN_HYPHEN, Category: &quot;hyphen&quot;},

    // 引用符号
    '@':  {TokenType: TOKEN_AT, Category: &quot;at&quot;},

    // 标签/注释符号
    '#': {TokenType: TOKEN_HASH, Category: &quot;hash&quot;},

    // 空白字符（空格、制表符、回车）
    // 注意：这些字符会被 skipWhitespace() 提前消费，不会作为 Token 返回
    ' ':  {TokenType: TOKEN_TEXT, Category: &quot;whitespace&quot;},
    '\t': {TokenType: TOKEN_TEXT, Category: &quot;whitespace&quot;},
    '\r': {TokenType: TOKEN_TEXT, Category: &quot;whitespace&quot;},

    // 换行符
    '\n': {TokenType: TOKEN_NEWLINE, Category: &quot;newline&quot;},
}
</code></pre>

<h4 id="关键设计说明">关键设计说明</h4>

<table>
<thead>
<tr>
<th>设计点</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>同一 TokenType 对应多个字符</strong></td>
<td><code>【</code> 和 <code>[</code> 都是 <code>TOKEN_LEFT_BRACKET</code>，中英双语自然支持</td>
</tr>

<tr>
<td><strong>Category 用于筛选</strong></td>
<td>Lexer 可以通过 <code>Category == &quot;whitespace&quot;</code> 跳过空格</td>
</tr>

<tr>
<td><strong><code>\n</code> 是 <code>TOKEN_NEWLINE</code></strong></td>
<td>换行有语法意义，会被作为 Token 返回（不同于被跳过的空格）</td>
</tr>

<tr>
<td><strong>空格是 <code>TOKEN_TEXT</code></strong></td>
<td>空格永远不会被返回（被 <code>skipWhitespace</code> 提前消费），<code>TOKEN_TEXT</code> 只是占位</td>
</tr>
</tbody>
</table>

<h2 id="七-基于映射表的符号查询">七、基于映射表的符号查询</h2>

<p>有了映射表，查询符号信息变得极其简单——直接查表即可：</p>

<pre><code class="language-go">// GetSymbolInfo 查询符号信息
// 返回值：(SymbolInfo, bool)，bool 表示是否找到
// 这是给外部包（如测试代码）用的查询入口
func GetSymbolInfo(ch rune) (SymbolInfo, bool) {
    info, ok := symbolMap[ch]
    return info, ok
}
</code></pre>

<h3 id="这个函数是-symbolmap-的唯一直观体现">这个函数是 <code>symbolMap</code> 的唯一直观体现</h3>

<p>它告诉调用方&rdquo;这个字符是不是我们认识的符号？如果是，它是什么类型？&rdquo;</p>

<p>在下一篇文章中，我们会看到 Lexer 内部如何使用 <code>symbolMap</code> 来实现一次查表驱动整个词法分析。现在我们先验证这张表是否工作正常。</p>

<h2 id="八-验证-查表逻辑的正确性">八、验证：查表逻辑的正确性</h2>

<p>在 <code>main.go</code> 中测试 <code>GetSymbolInfo</code>：</p>

<pre><code class="language-go">package main

import (
    &quot;fmt&quot;
    &quot;mephisto/internal/parser&quot;
)

func main() {
    // 查询中文左括号
    info, ok := parser.GetSymbolInfo('【')
    fmt.Printf(&quot;'【' → %s, 找到: %v\n&quot;, info.TokenType, ok) // LEFT_BRACKET, true

    // 查询英文左括号
    info, ok = parser.GetSymbolInfo('[')
    fmt.Printf(&quot;'[' → %s, 找到: %v\n&quot;, info.TokenType, ok) // LEFT_BRACKET, true

    // 查询普通字母（不在表中）
    info, ok = parser.GetSymbolInfo('x')
    fmt.Printf(&quot;'x' → 找到: %v\n&quot;, ok) // false

    // 查询换行符
    info, ok = parser.GetSymbolInfo('\n')
    fmt.Printf(&quot;'\\n' → %s, 找到: %v\n&quot;, info.TokenType, ok) // NEWLINE, true
}
</code></pre>

<p>输出：</p>

<pre><code class="language-text">'【' → TOKEN_LEFT_BRACKET, 找到: true
'[' → TOKEN_LEFT_BRACKET, 找到: true
'x' → 找到: false
'\n' → TOKEN_NEWLINE, 找到: true
</code></pre>

<h3 id="验证要点">验证要点</h3>

<ul>
<li><code>【</code> 和 <code>[</code> 都返回 <code>TOKEN_LEFT_BRACKET</code>——这就是中英双语支持</li>
<li><code>x</code> 不在表中——它会被当作普通文本处理</li>
<li><code>\n</code> 在表中，被识别为 <code>TOKEN_NEWLINE</code>——它有语法意义，会被返回</li>
</ul>

<p><strong>注意</strong>：空格不在验证列表中，因为空格被 <code>skipWhitespace()</code> 提前消费了，我们验证的重点是&rdquo;会被 Lexer 返回的符号&rdquo;。</p>

<h2 id="九-这种设计的优势">九、这种设计的优势</h2>

<table>
<thead>
<tr>
<th>优势</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>新增符号不改 Lexer</strong></td>
<td>只需在 <code>symbolMap</code> 加一行，Lexer 自动识别</td>
</tr>

<tr>
<td><strong>中英双语自然支持</strong></td>
<td>同一 TokenType 对应多个字符</td>
</tr>

<tr>
<td><strong>判断逻辑集中</strong></td>
<td>所有符号的判断都在一张表里</td>
</tr>

<tr>
<td><strong>Category 提供额外维度</strong></td>
<td>可以按分类批量处理（如跳过所有 whitespace）</td>
</tr>
</tbody>
</table>
<p><strong>核心思想：让数据决定一切。</strong></p>

<h2 id="十-小结">十、小结</h2>

<p>这一篇完成了两件事：</p>

<table>
<thead>
<tr>
<th>做了什么</th>
<th>为什么</th>
</tr>
</thead>

<tbody>
<tr>
<td>扩充了 Token 类型</td>
<td>Lexer 需要识别更多符号（冒号、连字符、引用符号等）</td>
</tr>

<tr>
<td>创建了 <code>symbolMap</code> 符号映射表</td>
<td>集中管理所有符号，Lexer 通过查表判断</td>
</tr>
</tbody>
</table>
<p><strong>下一篇：实现 Lexer，真正把文本变成 Token 流。</strong></p>
]]></content:encoded>
      <description><![CDATA[避免 Lexer 中出现大量 switch-case，引入符号映射表（symbolMap）统一管理所有符号，实现中英双语支持和数据驱动设计。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Compiler]]></category>
      <dc:relation><![CDATA[series:lexer-from-scratch]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零实现词法分析器（一）：Token——计算机的最小理解单位]]></title>
      <link>https://moongate.top/docs/from-text-to-token-go-lexer-part-1</link>
      <guid isPermaLink="true">https://moongate.top/docs/from-text-to-token-go-lexer-part-1</guid>
      <pubDate>Mon, 13 Jul 2026 20:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-问题-计算机看不懂文本的结构">一、问题：计算机看不懂文本的结构</h2>

<p>假设我有一个文本文件，内容是这样的：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<p>我（人类）一眼就能看出：</p>

<ul>
<li><code>【角色名】</code> 是一个标题</li>
<li><code>贝利亚奥特曼</code> 是它的内容</li>
</ul>

<p>但计算机看到的是：</p>

<pre><code class="language-text">[ 0xE3 0x80 0x90 0xE8 0xA7 0x92 0xE8 0x89 0xB2 0xE5 0x90 0x8D 0xE3 0x80 0x91 0x0A 0xE8 0xB4 0x9D 0xE5 0x88 0xA9 0xE4 0xBA 0x9A 0xE5 0xA5 0xA5 0xE7 0x89 0xB9 0xE6 0x9B 0xBC ]
</code></pre>

<p>这就是计算机眼中的文本——一串基于 <strong>UTF-8 编码</strong>的原始字节流。它不知道 <code>0xE3 0x80 0x90</code> 连起来就是中文的 <code>【</code>，也不知道 <code>【</code> 和 <code>】</code> 是配对的。</p>

<p>它看到的只是一串毫无意义的数字。</p>

<p>如果我想让程序读懂这种格式的文本，第一个要解决的问题就是：<strong>让计算机能“认出”文本的结构。</strong></p>

<h2 id="二-怎么办-拆成小块-贴上标签">二、怎么办：拆成小块，贴上标签</h2>

<p>计算机不认识“区块标题”这种概念，但它能识别字符。</p>

<p>如果我把这段文本拆成最小的“有意义的片段”，并给每个片段贴上<strong>固定的标签</strong>，计算机就能一步步理解它了。</p>

<p>比如，把：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<p>拆成：</p>

<table>
<thead>
<tr>
<th>片段</th>
<th>标签</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>【</code></td>
<td><code>LEFT_BRACKET</code></td>
<td>左括号</td>
</tr>

<tr>
<td><code>角色名</code></td>
<td><code>TEXT</code></td>
<td>普通文本</td>
</tr>

<tr>
<td><code>】</code></td>
<td><code>RIGHT_BRACKET</code></td>
<td>右括号</td>
</tr>

<tr>
<td><code>\n</code></td>
<td>（换行符）</td>
<td>文本中的换行</td>
</tr>

<tr>
<td><code>贝利亚奥特曼</code></td>
<td><code>TEXT</code></td>
<td>普通文本</td>
</tr>
</tbody>
</table>
<p>这样，程序就能知道：</p>

<ol>
<li>这里有一个左括号</li>
<li>后面跟了一段文本（“角色名”）</li>
<li>然后是一个右括号</li>
<li>换行后，又有一段文本（“贝利亚奥特曼”）</li>
</ol>

<p><strong>下一步，程序就可以根据这个规律，识别出“【】”包裹的是标题，标题下面跟着的是内容。</strong></p>

<h2 id="三-token-有标签的最小片段">三、Token：有标签的最小片段</h2>

<p>这种<strong>有标签的最小片段</strong>，就叫 <strong>Token</strong>。</p>

<p>每个 Token 需要记录两件事：</p>

<ol>
<li><strong>类型</strong>：这是什么？（左括号？文本？右括号？）</li>
<li><strong>字面量</strong>：它长什么样？（<code>【</code>？<code>角色名</code>？）</li>
</ol>

<p>把文本拆成 Token 的过程，叫<strong>词法分析</strong>。</p>

<p>执行词法分析的程序，叫 <strong>Lexer（词法分析器）</strong>。</p>

<h2 id="四-定义-token-第一行代码">四、定义 Token：第一行代码</h2>

<h3 id="4-1-项目结构">4.1 项目结构</h3>

<p>在开始写代码前，先建立清晰的模块化目录：</p>

<pre><code class="language-text">mephisto/
├── go.mod
├── main.go
├── testdata/
│   └── sample.meph
└── internal/
    └── parser/
        ├── token.go
        ├── symbols.go
        └── lexer.go
</code></pre>

<h4 id="注意">注意</h4>

<p>所有解析相关的代码都放在 <code>parser/</code> 目录下，这样随着系列文章的推进，我们始终在同一个包里工作，目录结构的变化只是“新增文件”，不是“切换包”。读者从头到尾只需要关注 <code>internal/parser/</code> 这一个目录的演进。</p>

<h3 id="4-2-初始化-go-模块">4.2 初始化 Go 模块</h3>

<pre><code class="language-bash">go mod init mephisto
</code></pre>

<h3 id="4-3-定义-token-类型与结构体">4.3 定义 Token 类型与结构体</h3>

<p>创建 <code>parser/token.go</code>：</p>

<pre><code class="language-go">package parser

// TokenType 表示 Token 的类型
// 用 string 而不是 int，调试时可以直接看到类型名
type TokenType string

const (
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;  // 【
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot; // 】
	TOKEN_TEXT          TokenType = &quot;TEXT&quot;          // 普通文本
	// 更多 Token 类型将在后续文章中逐步引入
)

// Token 是计算机理解文本的最小有意义片段
type Token struct {
	Type    TokenType // 类型：LEFT_BRACKET？TEXT？
	Literal string    // 字面量：&quot;【&quot;？&quot;角色名&quot;？
	Line    int       // Line 字段将在后续文章中引入（用于错误提示）
}
</code></pre>

<h4 id="为什么用-string-而不是-int-定义类型">为什么用 <code>string</code> 而不是 <code>int</code> 定义类型？</h4>

<p>如果用一个数字表示类型，调试时打印出来的是一串数字，需要翻代码才能知道 <code>0</code> 代表什么。而用 <code>string</code>，<code>fmt.Println(tok.Type)</code> 直接打印 <code>&quot;LEFT_BRACKET&quot;</code>，可读性高得多。词法分析器处理的 Token 数量通常只有几百个，<code>string</code> 的性能开销可以忽略不计。</p>

<h4 id="为什么-token-的字段要大写">为什么 <code>Token</code> 的字段要大写？</h4>

<p>在 Go 里，<strong>大写字母开头的字段是公开的</strong>，小写是私有的。<code>Token</code> 会被 <code>main.go</code> 使用，所以它的字段必须大写，否则外部包无法访问。</p>

<h2 id="五-验证-让代码能编译">五、验证：让代码能编译</h2>

<h3 id="5-1-创建入口文件">5.1 创建入口文件</h3>

<p>在项目根目录创建 <code>main.go</code>。目前它只负责读取文件并打印内容——虽然还没有用到 <code>parser</code> 包，但项目的骨架已经搭建完毕：</p>

<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;os&quot;
)

func main() {
	if len(os.Args) &lt; 2 {
		fmt.Println(&quot;用法: mephisto &lt;文件&gt;&quot;)
		os.Exit(1)
	}

	content, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Printf(&quot;读取文件失败: %v\n&quot;, err)
		os.Exit(1)
	}

	fmt.Print(string(content))
}
</code></pre>

<h3 id="5-2-创建测试文件">5.2 创建测试文件</h3>

<p><code>testdata/sample.meph</code>：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<h3 id="5-3-运行">5.3 运行</h3>

<pre><code class="language-bash">go run main.go testdata/sample.meph
</code></pre>

<p>看到文件内容输出，说明项目能跑了：</p>

<pre><code class="language-text">【角色名】
贝利亚奥特曼
</code></pre>

<h2 id="六-小结">六、小结</h2>

<p>这一篇完成了一件事：<strong>为整个系列打下地基。</strong></p>

<table>
<thead>
<tr>
<th>做了什么</th>
<th>为什么</th>
</tr>
</thead>

<tbody>
<tr>
<td>理解了 Token 的概念</td>
<td>Token 是计算机理解文本的最小单位</td>
</tr>

<tr>
<td>定义了 <code>TokenType</code> 和 <code>Token</code></td>
<td>为后续实现 Lexer 准备好数据结构</td>
</tr>

<tr>
<td>搭建了项目骨架</td>
<td>后续所有代码都在这个基础上扩展</td>
</tr>
</tbody>
</table>
<p><strong>接下来我们要做的就是实现 Lexer——让程序能自动从文本里“吐”出 Token。</strong></p>

<h2 id="完整代码">完整代码</h2>

<h3 id="parser-token-go"><code>parser/token.go</code></h3>

<pre><code class="language-go">package parser

type TokenType string

const (
	TOKEN_LEFT_BRACKET  TokenType = &quot;LEFT_BRACKET&quot;
	TOKEN_RIGHT_BRACKET TokenType = &quot;RIGHT_BRACKET&quot;
	TOKEN_TEXT          TokenType = &quot;TEXT&quot;
)

type Token struct {
	Type    TokenType
	Literal string
}
</code></pre>

<h3 id="main-go"><code>main.go</code></h3>

<pre><code class="language-go">package main

import (
	&quot;fmt&quot;
	&quot;os&quot;
)

func main() {
	if len(os.Args) &lt;  {
		fmt.Println(&quot;用法: mephisto &lt;文件&gt;&quot;)
		os.Exit(1)
	}

	content, err := os.ReadFile(os.Args[1])
	if err != nil {
		fmt.Printf(&quot;读取文件失败: %v\n&quot;, err)
		os.Exit(1)
	}

	fmt.Print(string(content))
}
</code></pre>
]]></content:encoded>
      <description><![CDATA[从计算机不认识文本的痛点出发，理解 Token 的概念，并用 Go 定义第一个 Token 结构体，完成词法分析器的第一步。]]></description>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:lexer-from-scratch]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 实战：在个人博客中集成 Shiki 自定义 VSCode 主题]]></title>
      <link>https://moongate.top/docs/nuxt-shiki-vscode-theme-ssr-dual-themes</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-shiki-vscode-theme-ssr-dual-themes</guid>
      <pubDate>Sat, 11 Jul 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="为什么要折腾-shiki">为什么要折腾 Shiki？</h2>

<p>在个人博客中，代码高亮是阅读体验的核心。市面上主流的 Prism.js 和 Highlight.js 虽然普及度高，但高亮精度有限。相比之下，Shiki 使用与 VSCode 相同的 TextMate 语法引擎，能实现像素级精准的高亮，完全对标 VSCode 的代码着色体验。</p>

<p>我的需求很简单：个人博客使用我自己开发的 VSCode 主题插件 <strong>Moongate Theme</strong> 的深浅两套配色，代码块在 SSR 场景下正常渲染，且深浅主题能一键联动切换。
听起来不难？<strong>实际上坑比想象中多。</strong></p>

<h2 id="坑一-自定义主题怎么加载">坑一：自定义主题怎么加载？</h2>

<p>Shiki 官方文档提供了两种常规的加载方式：创建高亮器时直接传入主题对象，或使用 loadTheme 动态加载 JSON 文件。看起来很简单对吧？但当把它放到 Nuxt 项目中时，问题来了。</p>

<h3 id="错误尝试-依赖-nuxt-shiki-模块">❌ 错误尝试：依赖 nuxt-shiki 模块</h3>

<p>我一开始用了 nuxt-shiki 模块，想着它能帮我省事。配置如下：</p>

<pre><code class="language-typescript">import lightTheme from &quot;./assets/themes/light.json&quot;
import darkTheme from &quot;./assets/themes/dark.json&quot;

export default defineNuxtConfig({
  modules: [&quot;nuxt-shiki&quot;],
  shiki: {
    bundledThemes: [lightTheme.name, darkTheme.name],
    defaultTheme: lightTheme.name,
  },
})
</code></pre>

<p>运行后直接报错：</p>

<pre><code class="language-text">Failed to resolve import &quot;shiki/themes/Moongate Theme Light.mjs&quot;
</code></pre>

<blockquote>
<p><strong>根本原因</strong>：nuxt-shiki 的 bundledThemes 只接受 Shiki <strong>内置主题的名称</strong>（如 github-dark），它会自动去 shiki/themes/ 目录下查找对应的内置 .mjs 文件。当你传入自定义主题名称时，它找不到对应的文件，自然 404。</p>
</blockquote>

<h3 id="正确做法-直接用原生-shiki-api">✅ 正确做法：直接用原生 Shiki API</h3>

<p>放弃 nuxt-shiki 模块，直接在 Composable 中使用原生 createHighlighter，将其作为常驻内存的全局单例：</p>

<pre><code class="language-typescript">// composables/useShikiHighlighter.ts
import { createHighlighter, type Highlighter } from &quot;shiki&quot;
import lightTheme from &quot;~/assets/themes/light.json&quot;
import darkTheme from &quot;~/assets/themes/dark.json&quot;

let highlighterInstance: Highlighter | null = null

export async function getShikiHighlighter() {
  if (!highlighterInstance) {
    highlighterInstance = await createHighlighter({
      themes: [lightTheme, darkTheme],
      langs: [
        &quot;bash&quot;,
        &quot;css&quot;,
        &quot;docker&quot;,
        &quot;go&quot;,
        &quot;html&quot;,
        &quot;javascript&quot;,
        &quot;json&quot;,
        &quot;markdown&quot;,
        &quot;shell&quot;,
        &quot;sql&quot;,
        &quot;typescript&quot;,
        &quot;vue&quot;,
        &quot;xml&quot;,
        &quot;yaml&quot;,
      ],
    })
  }
  return highlighterInstance
}
</code></pre>

<blockquote>
<p><strong>关键点</strong>：主题 JSON 对象直接传入 themes 数组，高亮时通过主题的 name 字段引用即可，不再依赖外部文件的动态寻址。</p>
</blockquote>

<h2 id="坑二-自定义主题的双主题联动">坑二：自定义主题的双主题联动</h2>

<h3 id="单主题方案的局限性">单主题方案的局限性</h3>

<p>最初的方案是在客户端组件挂载后通过 DOMParser 解析 HTML，然后根据当前主题传入对应的主题名称重新高亮：</p>

<pre><code class="language-typescript">// ❌ 客户端高亮 + watch 主题变化
const theme = isDark ? &quot;Moongate Theme Dark&quot; : &quot;Moongate Theme Light&quot;
const result = highlighter.codeToHtml(code, { lang, theme })

watch(() =&gt; store.theme, highlight) // 主题变了要重新处理所有代码块
</code></pre>

<p>这种传统做法会带来三个极具毁灭性的痛点：</p>

<ol>
<li><strong>闪动</strong>：客户端 Hydration 后才能高亮，用户打开网页会看到“原始内容/黑色外壳 → 高亮内容”的明显跳变。</li>
<li><strong>延迟</strong>：每次一键切换主题，客户端都需要重新执行 JS 高亮所有代码块，有明显的视觉等待时间。</li>
<li><strong>CPU 开销</strong>：在手机端低配设备上，每次切换主题都调用 WASM 引擎处理大量文本，会导致页面瞬间掉帧。</li>
</ol>

<h2 id="坑三-服务端高亮-dual-themes-完美方案">坑三：服务端高亮 + Dual Themes = 完美方案</h2>

<p>真正的突破是<strong>把高亮工作移到服务端完成首屏渲染</strong>，同时利用 Shiki 的 <strong>Dual Themes（双主题）</strong> 特性。</p>

<blockquote>
<p>⚠️ <strong>先说明一个边界</strong>：“把高亮移到服务端”消灭的是<strong>首屏</strong>（SSR 直出页面）的闪动与客户端高亮开销。但站内 SPA 导航（NuxtLink 跳转、无 SSR payload 命中）时，<code>useAsyncData</code>/<code>useLazyAsyncData</code> 的 <code>transform</code> 会在<strong>客户端再次执行</strong>——此时高亮仍需要 Shiki 在客户端可用。若你的 Nuxt 访问路径全部走服务端渲染，客户端确实接近“零高亮开销”；若存在站内客户端跳转，请把下面的方案理解为“首屏零开销 + 导航时按需高亮”，而不是绝对零开销。</p>
</blockquote>

<h3 id="核心工具函数-服务端高亮处理器">核心工具函数：服务端高亮处理器</h3>

<p>创建 utils/shikiProcessor.ts。这里使用正则异步替换未高亮的 HTML 块，并加入一个健壮的兜底机制：即使代码块没有写 language-xxx，也能默认以 text 纯文本进行高亮渲染。</p>

<pre><code class="language-typescript">// utils/shikiProcessor.ts
import { getShikiHighlighter } from &quot;~/composables/useShikiHighlighter&quot;

export async function highlightHtmlContent(
  htmlContent: string,
): Promise&lt;string&gt; {
  if (!htmlContent) return &quot;&quot;

  const highlighter = await getShikiHighlighter()

  // 增强正则：允许匹配没有定义 language 类的标准 &lt;code&gt; 块
  const preCodeRegex = /&lt;pre&gt;\s*&lt;code([^&gt;]*)&gt;([\s\S]*?)&lt;\/code&gt;\s*&lt;\/pre&gt;/g
  const matches = [...htmlContent.matchAll(preCodeRegex)]
  let resultHtml = htmlContent

  for (const match of matches) {
    const [fullMatch, attributes, rawCode] = match

    // 提取语言类型，若无则默认为 'text'
    const langMatch = attributes.match(/class=&quot;[^&quot;]*language-(\w+)&quot;/)
    const lang = langMatch ? langMatch[1] : &quot;text&quot;

    // 解码 HTML 实体，防止 Shiki 二次转义
    const code = rawCode
      .replace(/&amp;lt;/g, &quot;&lt;&quot;)
      .replace(/&amp;gt;/g, &quot;&gt;&quot;)
      .replace(/&amp;amp;/g, &quot;&amp;&quot;)
      .replace(/&amp;quot;/g, '&quot;')
      .replace(/&amp;#39;/g, &quot;'&quot;)

    // 🎯 关键：使用 Dual Themes 一次生成包含两套颜色 Token 的 HTML
    const highlighted = highlighter.codeToHtml(code, {
      lang,
      themes: {
        light: &quot;Moongate Theme Light&quot;,
        dark: &quot;Moongate Theme Dark&quot;,
      },
      defaultColor: false, // 核心配置：不生成默认内联 color，完全靠 CSS 变量驱动
    })

    resultHtml = resultHtml.replace(fullMatch, highlighted)
  }

  return resultHtml
}
</code></pre>

<h3 id="在数据获取层拦截并转换">在数据获取层拦截并转换</h3>

<p>在组件内部，利用 useLazyAsyncData 的 transform 选项。<strong>这步是最高级的优化</strong>：SSR 首屏时，数据在服务端被 Node.js 抓取到后瞬间完成高亮替换，数据吐到前端时就已经套好了 Shiki 的外衣。再次提醒：transform 是 Nuxt <code>useAsyncData</code> 的通用机制，它在 SSR 与客户端导航（重新获取数据）时都会运行——&rdquo;服务端完成高亮&rdquo;针对的是首屏直出场景。</p>

<pre><code class="language-typescript">const { data: page, pending } = useLazyAsyncData&lt;DocDetailResponse&gt;(
  `doc-${slug.value}`,
  async () =&gt; {
    const {
      public: { apiUrl },
    } = useRuntimeConfig()
    return await $fetch(`${apiUrl}/api/docs/${slug.value}`)
  },
  {
    watch: [slug],
    // 🔥 关键：首屏数据在服务端获取后立即高亮（SSR 直出）
    // 注意：SPA 客户端导航时此 transform 仍会在浏览器执行，需确保 Shiki 客户端可用
    transform: async (data) =&gt; {
      if (data &amp;&amp; data.content) {
        data.highlightedContent = await highlightHtmlContent(data.content)
      }
      return data
    },
  },
)

// 优先使用服务端已高亮的完全体 HTML
const contentRef = computed(
  () =&gt; page.value?.highlightedContent || page.value?.content || &quot;&quot;,
)
</code></pre>

<h3 id="css-变量控制双主题切换">CSS 变量控制双主题切换</h3>

<p>Dual Themes 生成的 HTML 中会精妙地包含 &ndash;shiki-light 和 &ndash;shiki-dark 两套 CSS 变量。配合 @nuxtjs/color-mode 切换时自动在 <html> 标记的 .dark 类，只需在全局样式表中写下几行映射，就能实现<strong>纯 CSS 级别的高性能切换</strong>：</p>

<pre><code class="language-css">/* 浅色模式默认映射 */
.shiki {
  background-color: var(--shiki-light-bg) !important;
  color: var(--shiki-light) !important;
}
.shiki span {
  color: var(--shiki-light) !important;
}

/* 深色模式映射 - 纯 CSS 触发，不经过任何 JS 运行时 */
.dark .shiki {
  background-color: var(--shiki-dark-bg) !important;
  color: var(--shiki-dark) !important;
}
.dark .shiki span {
  color: var(--shiki-dark) !important;
}
</code></pre>

<h2 id="为什么闪动消失了">⚡ 为什么闪动消失了？</h2>

<p>通过前后方案的对比，我们可以清晰地看到这个方案的效果（以下&rdquo;现在&rdquo;列均指 <strong>SSR 首屏</strong>场景；站内 SPA 导航仍需按需高亮，见坑三开篇的边界说明）：</p>

<table>
<thead>
<tr>
<th>阶段</th>
<th>之前（有闪动、有延迟）</th>
<th>现在（无闪动、首屏零 JS 高亮）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>服务端 (SSR)</strong></td>
<td>返回原始 HTML（未高亮的纯文本或暗色外壳）</td>
<td>返回高亮后的 HTML（<strong>已包含全量双主题样式</strong>）</td>
</tr>

<tr>
<td><strong>客户端挂载</strong></td>
<td>显示原始内容 → 加载 JS / WASM → 替换 DOM → 高亮变色</td>
<td><strong>直接显示高亮后的 HTML，没有任何视觉时差</strong></td>
</tr>

<tr>
<td><strong>主题一键切换</strong></td>
<td>watch 状态变化 → 耗费 CPU 重新高亮渲染</td>
<td><strong>纯 CSS 切换变量，无需重新高亮</strong></td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>核心突破</strong>：高亮工作在服务端首屏就位，客户端收到即用。由于两套变量早已直出，切换主题变成了浏览器的原生样式渲染，不再需要 Pinia 去跨组件追踪和重绘。</p>
</blockquote>

<h2 id="细节打磨-代码块边框与呼吸感">🎨 细节打磨：代码块边框与呼吸感</h2>

<p>最后，给代码块加点微弱的边框和极浅的阴影，能在长文阅读中有效地为代码建立视觉锚点，提升整体的呼吸感与精致度：</p>

<pre><code class="language-css">.shiki-content pre.shiki {
  padding: 1.25rem;
  border-radius: 0.5rem;
  overflow-x: auto;
  border: 1px solid #e5e7eb;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  transition:
    border-color 0.3s,
    box-shadow 0.3s;
}

.dark .shiki-content pre.shiki {
  border-color: #2d3748;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</code></pre>

<h2 id="架构设计流向">📐 架构设计流向</h2>

<pre><code class="language-text"> ┌─────────────────┐
 │  服务端 (SSR)    │
 └────────┬────────┘
          │
          ▼
 ┌─────────────────┐
 │ 获取原始内容     │
 └────────┬────────┘
          │
          ▼
 ┌──────────────────────────────────────────────┐
 │ transform: highlightHtmlContent()            │
 │  ├── 正则匹配 &lt;pre&gt;&lt;code&gt;                     │
 │  ├── Shiki 原生 API 生成 Dual Themes HTML    │
 │  └── 替换原始代码块                           │
 └────────────────┬─────────────────────────────┘
          │
          ▼
 ┌──────────────────────────────────────────────┐
 │ 返回高亮后的 HTML (含 --shiki-light / dark)   │
 └────────────────┬─────────────────────────────┘
          │
          ▼
 ┌─────────────────┐
 │ 客户端直接渲染   │
 └────────┬────────┘
          │
          ├─► 浅色模式 ──► 自动映射 --shiki-light (纯 CSS)
          └─► 深色模式 ──► 自动映射 --shiki-dark  (纯 CSS)

</code></pre>

<h2 id="总结">📝 总结</h2>

<table>
<thead>
<tr>
<th>遇到的坑</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td>nuxt-shiki 不支持自定义主题</td>
<td>弃用扩展模块，改用原生 createHighlighter 自定义导入</td>
</tr>

<tr>
<td>客户端高亮导致 SSR 闪动缺陷</td>
<td>利用 useLazyAsyncData 的 transform 在服务端完成首屏高亮</td>
</tr>

<tr>
<td>深浅主题一键切换存在明显的延迟</td>
<td>采用 Shiki Dual Themes 生成双主题 CSS 变量</td>
</tr>

<tr>
<td>主题联动需要复杂的 watch 重渲染</td>
<td>纯 CSS 变量控制主题切换，无需重新高亮</td>
</tr>
</tbody>
</table>
<p>在前端实战中，面对长文章下的代码高亮需求，<strong>“在服务端多做一点，客户端首屏就能少做很多”</strong>。通过在服务端利用 Shiki 提取双主题直出，不仅消灭了视觉闪烁，还让博客客户端首屏免受庞大高亮引擎的加载负担。需要再次强调的是：这套收益针对的是 SSR 首屏；如果站点存在站内客户端导航，应确保 Shiki 在客户端按需可用，并把方案定位为“首屏直出 + 导航按需高亮”。</p>

<h2 id="服务端高亮的代价与边界">⚠️ 服务端高亮的代价与边界</h2>

<p>没有免费的午餐——把高亮搬到服务端，代价也随之转移：</p>

<ol>
<li><strong>常驻内存</strong>：高亮器单例（WASM 引擎 + 十余种语法数据）一旦创建便常驻服务端进程，占用数十 MiB 内存。对 2G 的小服务器，这是需要计入预算的开销。</li>
<li><strong>SSR payload 变大</strong>：<code>transform</code> 会把高亮后的完整 HTML（含每个 token 的双主题 CSS 变量）放进 SSR payload，文章越长 payload 越大，网络传输与反序列化成本上升。</li>
<li><strong>与全局状态叠加的风险</strong>：若把高亮器或相关状态做成模块级可变全局变量，在 SSR 长驻进程下可能跨请求累积——这是服务端内存增长的常见来源，我在另一篇复盘里完整记录过一次真实事故：<a href="./nuxt-ssr-memory-leak-troubleshooting">《Nuxt SSR 内存泄漏排查实录》</a>。小内存服务器的取舍建议：按需控制加载的语法数量、避免把高亮结果无限缓存，必要时提供 <code>dispose()</code> 释放高亮器实例。</li>
</ol>

<p>希望这篇实战记录能帮你在使用 Shiki + Nuxt 的路上少走弯路！</p>
]]></content:encoded>
      <description><![CDATA[从加载自定义主题到深浅色联动，再到彻底解决 SSR 闪动问题。一份完整的 Nuxt + Shiki 自定义主题集成指南。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[VSCode]]></category>
      <category><![CDATA[Theme]]></category>
      <category><![CDATA[Performance]]></category>
      <category><![CDATA[Hydration]]></category>
      <dc:relation><![CDATA[series:design-system]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt + Go 全栈实践：从 URL 状态到后端 API 的完整闭环]]></title>
      <link>https://moongate.top/docs/nuxt-go-fullstack-closed-loop</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-go-fullstack-closed-loop</guid>
      <pubDate>Sat, 11 Jul 2026 21:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文是系列第四篇，将前三篇的 URL 状态管理延伸至 Go 后端，实现分页、筛选、排序的端到端数据流。涵盖前后端参数约定、Go Gin 框架实践、useAsyncData 自动联动，以及 39 篇文档从 4 分钟到 10 秒的部署优化。</p>
</blockquote>

<h2 id="适用读者">适用读者</h2>

<p>已了解 Nuxt URL 状态同步（前三篇），想打通前后端完整数据流的开发者。</p>

<h3 id="你将学到">你将学到</h3>

<ul>
<li>前后端参数约定的设计方法</li>
<li>Go Gin 框架中处理分页、筛选、排序的实践</li>
<li>前端 <code>useAsyncData</code> 与后端 API 的自动联动</li>
<li>从 URL 状态到后端响应的完整数据流闭环</li>
</ul>

<h2 id="一-前置阅读">一、前置阅读</h2>

<p>本文假设你已经了解：</p>

<ul>
<li><strong>前端 URL 状态同步</strong>（前三篇已覆盖）</li>
<li><strong>Go 如何加载 Markdown 文件到内存</strong>（独立短文<a href="./go-markdown-loader">《用 Go 重构 Markdown 加载》</a>已覆盖）</li>
</ul>

<p>如果你还不熟悉 Go 数据加载部分，建议先阅读独立短文（非系列，10 分钟读完），再回到本篇。</p>

<blockquote>
<p>📖 关于 Go 后端的数据模型（<code>Doc</code> 结构体）和加载逻辑（<code>loader</code> 包），本文不再重复。下文直接使用已加载到内存的 <code>Store</code>。</p>
</blockquote>

<h2 id="二-整体架构">二、整体架构</h2>

<h3 id="2-1-数据流向图">2.1 数据流向图</h3>

<pre><code>┌─────────────────────────────────────────────────────────────────────────────┐
│                           前端（Nuxt）                                    │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  用户操作：输入搜索、选择等级、点击标签、翻页                          │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
│                                    ▼                                       │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  useRouteQuery：状态 ↔ URL 双向同步（前三篇）                        │ │
│  │  └── search, searchMode, page, size, level, tags                    │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
│                                    ▼                                       │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  useAsyncData：自动响应状态变化，构建 API 请求                       │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
└────────────────────────────────────┼───────────────────────────────────────┘
                                     │ HTTP GET
                                     ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                        后端（Go + Gin）                                    │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  GET /api/docs?page=1&amp;limit=10&amp;search=nuxt&amp;level=P3&amp;tag=go&amp;tag=vue  │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
│                                    ▼                                       │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  1. 解析参数：page, limit, search, searchMode, level, tags           │ │
│  │  2. 校验参数：searchMode 必须是 all/title/description               │ │
│  │  3. 数据处理：排序、筛选、分页                                      │ │
│  │  4. 返回响应：{ data, total, page, limit, totalPages }              │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
│                                    │                                       │
└────────────────────────────────────┼───────────────────────────────────────┘
                                     │
                                     ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                   数据源（独立短文已覆盖：MD → 内存）                      │
│  ┌───────────────────────────────────────────────────────────────────────┐ │
│  │  39 篇 Markdown 文档，启动时加载到内存                              │ │
│  │  ├── title, description, date, permalink, level, series, tags       │ │
│  │  └── content（HTML 正文）                                           │ │
│  └───────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="2-2-本篇聚焦">2.2 本篇聚焦</h3>

<pre><code class="language-text">系列前三篇：URL ↔ 前端状态（已完成）
独立短文：  MD 文件 → 内存 Store（已完成）
本篇：      前端状态 → API 参数 → Go 处理 → 响应返回（进行中）
</code></pre>

<h2 id="三-前后端参数约定">三、前后端参数约定</h2>

<h3 id="3-1-api-设计">3.1 API 设计</h3>

<h4 id="接口定义">接口定义</h4>

<pre><code class="language-text">GET /api/docs
</code></pre>

<h4 id="请求参数">请求参数</h4>

<table>
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>默认值</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>page</code></td>
<td>int</td>
<td>1</td>
<td>当前页码（从 1 开始）</td>
</tr>

<tr>
<td><code>limit</code></td>
<td>int</td>
<td>10</td>
<td>每页条数（可选 10/20/50）</td>
</tr>

<tr>
<td><code>search</code></td>
<td>string</td>
<td>&rdquo;&rdquo;</td>
<td>搜索关键词</td>
</tr>

<tr>
<td><code>searchMode</code></td>
<td>string</td>
<td>&ldquo;all&rdquo;</td>
<td>搜索模式：<code>all</code> / <code>title</code> / <code>description</code></td>
</tr>

<tr>
<td><code>level</code></td>
<td>string</td>
<td>&rdquo;&rdquo;</td>
<td>等级筛选：<code>P1</code> ~ <code>P5</code></td>
</tr>

<tr>
<td><code>tag</code></td>
<td>string[]</td>
<td>[]</td>
<td>标签筛选（支持多参数）</td>
</tr>
</tbody>
</table>

<h4 id="响应格式">响应格式</h4>

<pre><code class="language-json">{
  &quot;data&quot;: [
    {
      &quot;permalink&quot;: &quot;760e47b3-...&quot;,
      &quot;slug&quot;: &quot;nuxt-docs-list-page&quot;,
      &quot;title&quot;: &quot;构建一个功能完备的文档列表页&quot;,
      &quot;description&quot;: &quot;手把手教你用 Nuxt 4 构建...&quot;,
      &quot;level&quot;: &quot;P3&quot;,
      &quot;series&quot;: &quot;url-state&quot;,
      &quot;tags&quot;: [&quot;Nuxt&quot;, &quot;Vue&quot;, &quot;State Management&quot;],
      &quot;date&quot;: &quot;2026-03-21T00:00:00Z&quot;,
      &quot;content&quot;: &quot;&lt;h1&gt;...&lt;/h1&gt;&quot;
    }
  ],
  &quot;total&quot;: 39,
  &quot;page&quot;: 1,
  &quot;limit&quot;: 10,
  &quot;totalPages&quot;: 4
}
</code></pre>

<h3 id="3-2-前后端参数映射">3.2 前后端参数映射</h3>

<pre><code class="language-go">前端状态（useDocs）  →  URL 参数  →  Go 后端参数
─────────────────────────────────────────────────────
searchInput          →  search   →  c.Query(&quot;search&quot;)
searchMode           →  searchMode → c.Query(&quot;searchMode&quot;)
page                 →  page     →  c.Query(&quot;page&quot;)
size                 →  limit    →  c.Query(&quot;limit&quot;)
level                →  level    →  c.Query(&quot;level&quot;)
tags                 →  tag[]    →  c.QueryArray(&quot;tag&quot;)
</code></pre>

<h3 id="3-3-searchmode-枚举约定">3.3 searchMode 枚举约定</h3>

<pre><code class="language-go">// Go 后端
type SearchMode string

const (
    SearchModeAll         SearchMode = &quot;all&quot;         // 标题 + 描述
    SearchModeTitle       SearchMode = &quot;title&quot;       // 仅标题
    SearchModeDescription SearchMode = &quot;description&quot; // 仅描述
)

func (m SearchMode) IsValid() bool {
    return m == SearchModeAll || m == SearchModeTitle || m == SearchModeDescription
}
</code></pre>

<pre><code class="language-typescript">// 前端 Nuxt（与后端完全一致）
type SearchMode = &quot;all&quot; | &quot;title&quot; | &quot;description&quot;
</code></pre>

<h4 id="约定原则">约定原则</h4>

<p>枚举值前后端保持一致，任何非法值后端返回错误。</p>

<h2 id="四-go-api-实现">四、Go API 实现</h2>

<h3 id="4-1-项目结构-与本篇相关的部分">4.1 项目结构（与本篇相关的部分）</h3>

<pre><code class="language-text">moongate-api/
├── cmd/server/main.go          # 入口
├── internal/
│   ├── domain/
│   │   ├── doc.go              # Doc 结构体（独立短文已覆盖）
│   │   └── search_mode.go      # SearchMode 枚举 ← 本篇
│   ├── api/
│   │   └── docs.go             # DocsHandler ← 本篇核心
│   └── loader/                 # 数据加载（独立短文已覆盖）
└── content/                    # Markdown 文件
</code></pre>

<blockquote>
<p>📖 <code>domain.Doc</code> 结构体和 <code>loader</code> 包的完整实现见独立短文《用 Go 重构 Markdown 加载》。</p>
</blockquote>

<h3 id="4-2-searchmode-枚举">4.2 SearchMode 枚举</h3>

<pre><code class="language-go">// internal/domain/search_mode.go
package domain

type SearchMode string

const (
    SearchModeAll         SearchMode = &quot;all&quot;
    SearchModeTitle       SearchMode = &quot;title&quot;
    SearchModeDescription SearchMode = &quot;description&quot;
)

func (m SearchMode) IsValid() bool {
    return m == SearchModeAll || m == SearchModeTitle || m == SearchModeDescription
}
</code></pre>

<h3 id="4-3-docshandler">4.3 DocsHandler</h3>

<pre><code class="language-go">// internal/api/docs.go
package api

import (
    &quot;moongate-api/internal/domain&quot;
    &quot;net/http&quot;
    &quot;sort&quot;
    &quot;strconv&quot;
    &quot;strings&quot;

    &quot;github.com/gin-gonic/gin&quot;
)

type DocsHandler struct {
    Store map[string]*domain.Doc // key = permalink（来自独立短文）
}

func NewDocsHandler(store map[string]*domain.Doc) *DocsHandler {
    return &amp;DocsHandler{Store: store}
}

// GetDocs 返回分页后的文章列表
// GET /api/docs?page=1&amp;limit=10&amp;search=vue&amp;searchMode=all&amp;level=P3&amp;tag=go&amp;tag=vue
func (h *DocsHandler) GetDocs(c *gin.Context) {
    // 1. 获取查询参数
    page := c.DefaultQuery(&quot;page&quot;, &quot;1&quot;)
    limit := c.DefaultQuery(&quot;limit&quot;, &quot;10&quot;)
    search := c.Query(&quot;search&quot;)
    searchMode := domain.SearchMode(c.DefaultQuery(&quot;searchMode&quot;, &quot;all&quot;))
    level := c.Query(&quot;level&quot;)
    tags := c.QueryArray(&quot;tag&quot;)

    // 2. 校验 searchMode
    if !searchMode.IsValid() {
        c.JSON(http.StatusBadRequest, gin.H{
            &quot;error&quot;: &quot;searchMode 参数只能是 all、title 或 description&quot;,
        })
        return
    }

    // 3. 字符串转整数
    pageNum, _ := strconv.Atoi(page)
    limitNum, _ := strconv.Atoi(limit)

    // 4. 边界保护
    if pageNum &lt; 1 {
        pageNum = 1
    }
    if limitNum &lt; 1 {
        limitNum = 10
    }
    // 只允许 10、20、50
    allowedLimits := map[int]bool{10: true, 20: true, 50: true}
    if !allowedLimits[limitNum] {
        limitNum = 20
    }

    // 5. 转成切片并排序
    docs := make([]*domain.Doc, 0, len(h.Store))
    for _, doc := range h.Store {
        docs = append(docs, doc)
    }

    sort.Slice(docs, func(i, j int) bool {
        return docs[i].Date.After(docs[j].Date)
    })

    // 6. 筛选逻辑
    filtered := make([]*domain.Doc, 0, len(docs))
    levelUpper := strings.ToUpper(level)

    for _, doc := range docs {
        // 6.1 等级筛选
        if level != &quot;&quot; &amp;&amp; doc.Level != domain.Level(levelUpper) {
            continue
        }

        // 6.2 搜索筛选
        if search != &quot;&quot; {
            keyword := strings.ToLower(search)
            matchTitle := strings.Contains(strings.ToLower(doc.Title), keyword)
            matchDesc := strings.Contains(strings.ToLower(doc.Description), keyword)

            switch searchMode {
            case domain.SearchModeTitle:
                if !matchTitle {
                    continue
                }
            case domain.SearchModeDescription:
                if !matchDesc {
                    continue
                }
            default:
                if !matchTitle &amp;&amp; !matchDesc {
                    continue
                }
            }
        }

        // 6.3 标签筛选（AND 关系——必须包含所有选中标签）
        if len(tags) &gt; 0 &amp;&amp; !doc.ContainsAllTags(tags) {
            continue
        }

        filtered = append(filtered, doc)
    }

    // 7. 分页
    total := len(filtered)
    start := (pageNum - 1) * limitNum
    end := start + limitNum

    // 边界保护 + 兜底
    if start &gt; total {
        start = total
    }
    if end &gt; total {
        end = total
    }
    if start &gt; end {
        start = end
    }

    pagedDocs := filtered[start:end]

    // 8. 返回结果
    c.JSON(http.StatusOK, gin.H{
        &quot;data&quot;:       pagedDocs,
        &quot;total&quot;:      total,
        &quot;page&quot;:       pageNum,
        &quot;limit&quot;:      limitNum,
        &quot;totalPages&quot;: (total + limitNum - 1) / limitNum,
    })
}
</code></pre>

<h3 id="4-4-主程序入口">4.4 主程序入口</h3>

<pre><code class="language-go">// cmd/server/main.go
package main

import (
    &quot;log&quot;
    &quot;moongate-api/internal/api&quot;
    &quot;moongate-api/internal/loader&quot;

    &quot;github.com/gin-gonic/gin&quot;
)

func main() {
    // 1. 加载数据到内存（独立短文已覆盖）
    store, err := loader.LoadAll(&quot;content/&quot;)
    if err != nil {
        log.Fatal(&quot;加载内容失败:&quot;, err)
    }

    log.Printf(&quot;✅ 加载完成: %d 篇文章\n&quot;, len(store.Docs))

    // 2. 创建 Handler
    docsHandler := api.NewDocsHandler(store.Docs)

    // 3. 设置路由
    r := gin.Default()
    r.GET(&quot;/api/docs&quot;, docsHandler.GetDocs)
    r.GET(&quot;/api/docs/:permalink&quot;, docsHandler.GetDoc)

    // 4. 启动服务
    r.Run(&quot;:8080&quot;)
}
</code></pre>

<h3 id="4-5-标签筛选的算法说明">4.5 标签筛选的算法说明</h3>

<p>多标签筛选有两种实现方式：</p>

<table>
<thead>
<tr>
<th>方式</th>
<th>含义</th>
<th>用户期望</th>
<th>本项目的选择</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>OR（交集）</strong></td>
<td>包含任意一个标签即可</td>
<td>&ldquo;标签 A 或 B 相关的文章&rdquo;</td>
<td>❌</td>
</tr>

<tr>
<td><strong>AND（全包含）</strong></td>
<td>必须包含所有标签</td>
<td>&ldquo;同时涉及 A 和 B 的文章&rdquo;</td>
<td>✅</td>
</tr>
</tbody>
</table>
<p>本项目使用 <strong>AND 关系</strong>，即用户选中多个标签时，只返回同时包含所有这些标签的文章。这种&rdquo;无序全包含&rdquo;的集合逻辑更符合&rdquo;多条件精确筛选&rdquo;的直觉。</p>

<p>正如独立短文《用 Go 重构 Markdown 加载》中所沉淀的领域模型，<code>ContainsAllTags</code> 方法基于 <code>map</code> 实现了 O(n) 的高效集合逻辑：</p>

<pre><code class="language-go">// internal/domain/doc.go（独立短文已覆盖）
func (d *Doc) ContainsAllTags(targetTags []string) bool {
    if len(targetTags) == 0 {
        return true
    }
    if len(targetTags) &gt; len(d.Tags) {
        return false
    }

    tagSet := make(map[string]bool, len(d.Tags))
    for _, t := range d.Tags {
        tagSet[strings.ToLower(t)] = true
    }

    for _, t := range targetTags {
        if !tagSet[strings.ToLower(t)] {
            return false
        }
    }
    return true
}
</code></pre>

<h2 id="五-前端-usedocs-实现">五、前端 useDocs 实现</h2>

<h3 id="5-1-useroutequery-封装">5.1 useRouteQuery 封装</h3>

<p>前三篇已完整实现，此处仅列出与本篇相关的使用方式：</p>

<pre><code class="language-typescript">// composables/useRouteQuery.ts
// 完整实现见系列第三篇[《手写一个更适合 Nuxt 的 useRouteQuery》](./nuxt-use-route-query-composables)

export function useRouteQueryString(
  name: string,
  options?: { defaultValue?: string },
)
export function useRouteQueryNumber(
  name: string,
  options?: { defaultValue?: number },
)
export function useRouteQueryArray(name: string)
</code></pre>

<h3 id="5-2-usedocs-composable">5.2 useDocs Composable</h3>

<pre><code class="language-typescript">// composables/useDocs.ts
import { createSharedComposable } from &quot;@vueuse/core&quot;
import {
  useRouteQueryString,
  useRouteQueryNumber,
  useRouteQueryArray,
} from &quot;./useRouteQuery&quot;

interface DocItem {
  permalink: string
  slug: string
  title: string
  description: string
  level: string
  series: string | null
  tags: string[]
  date: string
  content: string
}

interface DocsResponse {
  data: DocItem[]
  total: number
  page: number
  limit: number
  totalPages: number
}

const DEFAULTS = {
  search: &quot;&quot;,
  searchMode: &quot;all&quot;,
  page: 1,
  size: 10,
  viewMode: 1,
  level: &quot;&quot;,
} as const

const _useDocs = () =&gt; {
  // URL 同步状态（来自前三篇）
  const searchInput = useRouteQueryString(&quot;search&quot;, {
    defaultValue: DEFAULTS.search,
  })
  const searchMode = useRouteQueryString(&quot;searchMode&quot;, {
    defaultValue: DEFAULTS.searchMode,
  })
  const page = useRouteQueryNumber(&quot;page&quot;, { defaultValue: DEFAULTS.page })
  const size = useRouteQueryNumber(&quot;size&quot;, { defaultValue: DEFAULTS.size })
  const viewMode = useRouteQueryNumber(&quot;viewMode&quot;, {
    defaultValue: DEFAULTS.viewMode,
  })
  const level = useRouteQueryString(&quot;level&quot;, { defaultValue: DEFAULTS.level })
  const tags = useRouteQueryArray(&quot;tag&quot;)

  // 筛选变化时重置页码
  watch(
    [searchInput, searchMode, level, tags],
    () =&gt; {
      page.value = DEFAULTS.page
    },
    { deep: true },
  )

  // 构建请求参数
  // 注意：使用 URLSearchParams 的多参数格式（?tag=go&amp;tag=vue）
  // 与 Gin 的 c.QueryArray(&quot;tag&quot;) 天然兼容
  const queryParams = computed(() =&gt; {
    const params = new URLSearchParams()
    params.append(&quot;page&quot;, String(page.value))
    params.append(&quot;limit&quot;, String(size.value))

    if (searchInput.value.trim()) {
      params.append(&quot;search&quot;, searchInput.value.trim())
    }
    if (searchMode.value !== DEFAULTS.searchMode) {
      params.append(&quot;searchMode&quot;, searchMode.value)
    }
    if (level.value) {
      params.append(&quot;level&quot;, level.value)
    }

    // 标签：展开后逐项添加
    tags.value.forEach((t) =&gt; params.append(&quot;tag&quot;, t))

    return params
  })

  // 调用 Go API
  const { data, pending, refresh, error } = useAsyncData(
    &quot;docs-list&quot;,
    async () =&gt; {
      const {
        public: { apiUrl },
      } = useRuntimeConfig()
      return await $fetch&lt;DocsResponse&gt;(
        `${apiUrl}/docs?${queryParams.value.toString()}`,
      )
    },
    {
      watch: [searchInput, searchMode, page, size, level, tags],
    },
  )

  const resetFilters = () =&gt; {
    searchInput.value = DEFAULTS.search
    searchMode.value = DEFAULTS.searchMode
    page.value = DEFAULTS.page
    size.value = DEFAULTS.size
    viewMode.value = DEFAULTS.viewMode
    level.value = DEFAULTS.level
    tags.value = []
  }

  return {
    searchInput,
    searchMode,
    page,
    size,
    viewMode,
    level,
    tags,
    docs: data,
    pending,
    error,
    refresh,
    resetFilters,
  }
}

export const useDocs = createSharedComposable(_useDocs)
</code></pre>

<h3 id="5-3-组件中使用">5.3 组件中使用</h3>

<pre><code class="language-vue">&lt;!-- pages/docs/index.vue --&gt;
&lt;template&gt;
  &lt;div&gt;
    &lt;SearchHeader
      v-model:search=&quot;searchInput&quot;
      v-model:searchMode=&quot;searchMode&quot;
      v-model:viewMode=&quot;viewMode&quot;
    /&gt;

    &lt;TagFilter v-model:tags=&quot;tags&quot; /&gt;

    &lt;DocList :docs=&quot;docs?.data || []&quot; :viewMode=&quot;viewMode&quot; :pending=&quot;pending&quot; /&gt;

    &lt;Pagination
      v-if=&quot;docs &amp;&amp; docs.totalPages &gt; 1&quot;
      v-model:page=&quot;page&quot;
      :totalPages=&quot;docs.totalPages&quot;
      :total=&quot;docs.total&quot;
      :limit=&quot;docs.limit&quot;
    /&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
const {
  searchInput,
  searchMode,
  page,
  size,
  viewMode,
  level,
  tags,
  docs,
  pending,
  resetFilters,
} = useDocs()
&lt;/script&gt;
</code></pre>

<h2 id="六-数据流完整闭环">六、数据流完整闭环</h2>

<h3 id="6-1-用户操作触发流程">6.1 用户操作触发流程</h3>

<pre><code>用户输入 &quot;nuxt&quot; 到搜索框
    │
    ▼
searchInput 变化（useRouteQueryString）
    │
    ├─ watch 触发 → 更新 URL (?search=nuxt)
    │
    └─ watch 触发 → page 重置为 1
    │
    ▼
useAsyncData 的 watch 检测到依赖变化
    │
    ▼
调用 Go API：GET /api/docs?search=nuxt&amp;searchMode=all&amp;page=1&amp;limit=10
    │
    ▼
Go 后端处理：
    1. 接收参数 search=&quot;nuxt&quot;, searchMode=&quot;all&quot;
    2. 遍历内存中的 39 篇文章，搜索标题和描述
    3. 排序、分页
    4. 返回 JSON
    │
    ▼
前端渲染更新后的列表
</code></pre>

<h3 id="6-2-浏览器后退触发流程">6.2 浏览器后退触发流程</h3>

<pre><code>用户点击浏览器后退按钮
    │
    ▼
URL 从 ?search=nuxt 变为 ?search=vue
    │
    ▼
useRouteQueryRaw 监听到 route.query 变化
    │
    ▼
searchInput.value = &quot;vue&quot;（同步到内部状态）
    │
    ▼
useAsyncData 的 watch 检测到 searchInput 变化
    │
    ▼
重新调用 Go API，数据更新
</code></pre>

<h3 id="6-3-完整数据流图">6.3 完整数据流图</h3>

<pre><code>┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  用户操作   │───▶│  URL 变化   │───▶│  状态变化   │───▶│  API 请求   │
└─────────────┘    └─────────────┘    └─────────────┘    └──────┬──────┘
                                                                 │
                                                                 ▼
┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  页面渲染   │◀───│  数据更新   │◀───│  响应返回   │◀───│  Go 后端   │
└─────────────┘    └─────────────┘    └─────────────┘    └─────────────┘
</code></pre>

<h2 id="七-核心设计决策">七、核心设计决策</h2>

<h3 id="7-1-为什么-limit-只允许-10-20-50">7.1 为什么 limit 只允许 10/20/50？</h3>

<pre><code class="language-go">allowedLimits := map[int]bool{10: true, 20: true, 50: true}
if !allowedLimits[limitNum] {
    limitNum = 20
}
</code></pre>

<ul>
<li>给用户明确的选项，减少困惑</li>
<li>防止恶意请求（如 <code>limit=999999</code>）</li>
<li>与前端 UI 选项保持一致</li>
</ul>

<h3 id="7-2-为什么用内存存储">7.2 为什么用内存存储？</h3>

<p>本项目目前 39 篇文章，总体积不足 1MB。内存存储方案：</p>

<ul>
<li>启动加载 &lt; 50ms</li>
<li>API 响应 &lt; 20ms</li>
<li>零外部依赖（不需要数据库）</li>
</ul>

<p>架构设计没有绝对的优劣，只有特定场景下的帕累托最优。</p>

<blockquote>
<p>简单说：对于现阶段 39 篇、不足 1MB 的静态资产，&rdquo;内存即数据库&rdquo;不是技术上最先进的方案，但在&rdquo;开发效率、响应速度、运维成本&rdquo;这三个维度上，它达到了当前场景下的最优平衡。</p>
</blockquote>

<h3 id="7-3-标签参数格式">7.3 标签参数格式</h3>

<p>前端 <code>useRouteQueryArray</code> 使用多参数格式：</p>

<pre><code>?tag=go&amp;tag=vue
</code></pre>

<p>Gin 的 <code>c.QueryArray(&quot;tag&quot;)</code> 原生支持这种格式，直接解析为 <code>[&quot;go&quot;, &quot;vue&quot;]</code>：</p>

<pre><code class="language-go">tags := c.QueryArray(&quot;tag&quot;)  // [&quot;go&quot;, &quot;vue&quot;] ✅
</code></pre>

<h2 id="八-迁移收益">八、迁移收益</h2>

<table>
<thead>
<tr>
<th>指标</th>
<th>迁移前（Nuxt Content）</th>
<th>迁移后（Go API）</th>
</tr>
</thead>

<tbody>
<tr>
<td>内容部署时间</td>
<td>3-4 分钟（完整构建）</td>
<td>~10 秒（同步文件）</td>
</tr>

<tr>
<td>技术透明度</td>
<td>❌ 黑盒</td>
<td>✅ 全透明</td>
</tr>

<tr>
<td>多端支持</td>
<td>❌ 仅 Nuxt</td>
<td>✅ REST API 通用</td>
</tr>

<tr>
<td>API 响应时间</td>
<td>Nuxt 渲染 + 查询</td>
<td>&lt; 20ms（内存读取）</td>
</tr>

<tr>
<td>依赖体积</td>
<td>Content + zod + shiki + 其他</td>
<td>3 个 Go 包</td>
</tr>
</tbody>
</table>

<h2 id="九-结语">九、结语</h2>

<p>本篇将前三篇的 URL 状态管理延伸到了 Go 后端，完成了从前端到后端的完整数据流闭环。</p>

<h3 id="系列四篇的演进路径">系列四篇的演进路径</h3>

<table>
<thead>
<tr>
<th>篇目</th>
<th>核心内容</th>
<th>技术栈</th>
</tr>
</thead>

<tbody>
<tr>
<td>1</td>
<td>URL ↔ 状态双向同步原理</td>
<td>Nuxt + Vue Router</td>
</tr>

<tr>
<td>2</td>
<td>完整文档列表页实现（手写方案）</td>
<td>Nuxt 前端</td>
</tr>

<tr>
<td>3</td>
<td>useRouteQuery 可复用封装</td>
<td>Nuxt + Composition API</td>
</tr>

<tr>
<td><strong>4</strong></td>
<td><strong>URL 状态 → Go API → 完整数据流闭环</strong></td>
<td><strong>Nuxt + Go</strong></td>
</tr>
</tbody>
</table>
<p>你现在拥有的是一套完整可复用的全栈架构：</p>

<ul>
<li>前端：URL 驱动的状态管理 + 自动响应式数据获取</li>
<li>后端：类型安全的 Gin API + 清晰的分层架构</li>
<li>数据：内存存储，毫秒级响应</li>
<li>约定：前后端参数统一，多标签筛选支持 AND 关系</li>
</ul>

<p>这套架构已经在实际项目中稳定运行，希望也能帮到正在构建类似系统的开发者。🎯</p>
]]></content:encoded>
      <description><![CDATA[将前三篇的 URL 状态管理延伸至 Go 后端，实现分页、筛选、排序的端到端数据流。涵盖前后端参数约定、Go Gin 框架实践、useAsyncData 自动联动，以及 39 篇文档从 4 分钟到 10 秒的部署优化。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Go]]></category>
      <category><![CDATA[State Management]]></category>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[VitePress 文档站接入已有 Docker 基础设施：子域名部署（扩展篇）]]></title>
      <link>https://moongate.top/docs/vitepress-docker-existing-infrastructure-subdomain-deployment</link>
      <guid isPermaLink="true">https://moongate.top/docs/vitepress-docker-existing-infrastructure-subdomain-deployment</guid>
      <pubDate>Sun, 07 Jun 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文记录如何将 VitePress 文档站部署为子域名，并接入已有 docker-compose 管理的动态站点（如 Nuxt 博客），共用同一 Caddy 反向代理。</p>
</blockquote>

<h2 id="前置说明">📌 前置说明</h2>

<h3 id="本文的部署环境">本文的部署环境</h3>

<ul>
<li><strong>主站点</strong>：Nuxt 构建的个人博客（<code>moongate.top</code>），通过 docker-compose 管理（PostgreSQL + Nuxt + Caddy）</li>
<li><strong>文档站</strong>：VitePress 构建的组件库文档，部署到子域名 <code>vue.moongate.top</code></li>
<li><strong>目标</strong>：将文档站容器接入现有的 Caddy 反向代理，复用同一套 Docker 网络和域名基础设施</li>
</ul>

<h3 id="如果你是从零开始部署">如果你是从零开始部署</h3>

<ul>
<li>本文部分内容（如网络连接）可简化</li>
<li>直接 <code>docker run -p 80:80</code> 即可运行</li>
</ul>

<p>本文的核心价值在于 <strong>VitePress 静态站如何与现有 Docker 基础设施整合</strong>，而非从零搭建。</p>

<blockquote>
<p><strong>关于静态文件目录</strong>：本文沿用系列教程的自定义目录风格（<code>/docs</code>）。如果你习惯使用 Caddy 默认目录（<code>/srv</code> 或 <code>/usr/share/caddy</code>），可相应调整，不影响最终效果。</p>
</blockquote>

<h2 id="适用场景">🎯 适用场景</h2>

<ul>
<li>✅ 已有 docker-compose 管理的动态站点（如 Nuxt 博客），需要将静态文档站作为子域名接入</li>
<li>✅ 希望多个站点共用同一 Caddy 反向代理和 HTTPS 证书</li>
<li>✅ 使用 Docker 部署静态网站，但不熟悉网络配置</li>
<li>✅ 遇到 pnpm 供应链安全检查报错，需要解决方案</li>
<li>✅ 需要将 VitePress 文档站接入自动化部署</li>
</ul>

<h2 id="版本声明">📌 版本声明</h2>

<p>Node.js、pnpm、Docker、Caddy、GitHub Actions 的版本信息与<a href="./docker-quickstart-auto-deploy">入门篇</a>一致。本文额外涉及：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>VitePress</td>
<td>1.6.x</td>
<td>文档生成器</td>
</tr>
</tbody>
</table>

<h2 id="系统架构-接入现有基础设施">🏗️ 系统架构（接入现有基础设施）</h2>

<blockquote>
<p><strong>背景</strong>：本文档站部署在已有 Nuxt 博客的基础设施之上。主站点 <code>moongate.top</code> 由 Nuxt + PostgreSQL + Caddy 组成，通过 docker-compose 管理。文档站作为子域名 <code>vue.moongate.top</code> 接入同一 Caddy。
<strong>架构说明</strong>：实际运行中存在两个 Caddy：</p>

<ul>
<li><strong>网关 Caddy</strong>（docker-compose 管理）：接收外部 HTTPS 请求，反向代理到内部服务</li>
<li><strong>文档站 Caddy</strong>（<code>moongate-vue</code> 容器内）：仅负责服务静态文件，监听 80 端口</li>
</ul>

<p>由于两者在同一 Docker 网络中，网关 Caddy 通过 <code>reverse_proxy moongate-vue:80</code> 直接通信，无需暴露端口到宿主机。容器间通信为内网明文 HTTP（80 端口），外层 HTTPS 证书由网关 Caddy 自动托管解析。</p>
</blockquote>

<pre><code class="language-text">┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 本地开发 │────▶│ GitHub Actions │────▶│ 阿里云 ACR │
│ git push │ │ 自动构建镜像 │ │ 镜像仓库 │
└─────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 阿里云 ECS 服务器 │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Docker 网络: my-blog_blog-network │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Caddy │ │ Nuxt 应用 │ │ PostgreSQL │ │ │
│ │ │ (网关代理) │◀──▶│ (博客) │◀──▶│ (数据库) │ │ │
│ │ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │
│ │ └──│ moongate-vue │ │ │
│ │ │ (VitePress + 内部 Caddy 静态服务) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
</code></pre>

<h2 id="与动态应用的核心差异">🔄 与动态应用的核心差异</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>Nuxt 动态应用</th>
<th>VitePress 静态站</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>构建产物</strong></td>
<td><code>.output</code> 服务端代码</td>
<td>纯静态 HTML/CSS/JS</td>
</tr>

<tr>
<td><strong>运行环境</strong></td>
<td>Node.js 运行时</td>
<td>静态文件服务器</td>
</tr>

<tr>
<td><strong>基础镜像</strong></td>
<td><code>node:alpine</code></td>
<td><code>caddy:alpine</code></td>
</tr>

<tr>
<td><strong>端口映射</strong></td>
<td>需要暴露端口</td>
<td>内部网络访问，无需端口</td>
</tr>

<tr>
<td><strong>数据库</strong></td>
<td>PostgreSQL</td>
<td>无</td>
</tr>

<tr>
<td><strong>环境变量</strong></td>
<td>多个敏感配置</td>
<td>无</td>
</tr>

<tr>
<td><strong>管理方式</strong></td>
<td>docker-compose 编排</td>
<td>独立 <code>docker run</code> + 网络连接</td>
</tr>

<tr>
<td><strong>Caddy 代理</strong></td>
<td><code>reverse_proxy app:3000</code></td>
<td><code>reverse_proxy moongate-vue:80</code></td>
</tr>
</tbody>
</table>

<h2 id="第一步-dockerfile-编写">🐳 第一步：Dockerfile 编写</h2>

<p>VitePress 是静态站点生成器，构建后输出纯静态文件。因此 Dockerfile 分为两个阶段：</p>

<pre><code class="language-bash"># ==================== 构建阶段 ====================
FROM node:24-alpine AS builder

WORKDIR /app

# 安装 git（VitePress 1.x 需要 git 来获取最后更新时间）
RUN apk add --no-cache git

# 安装 pnpm
RUN corepack enable &amp;&amp; corepack prepare pnpm@latest --activate

# 复制依赖文件
COPY package.json pnpm-lock.yaml ./

# ⚠️ 关键点：使用 --ignore-scripts 跳过 pnpm 的供应链安全检查
# 否则会触发 ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION
RUN pnpm install --frozen-lockfile --ignore-scripts

# 复制源代码并构建
COPY . .
RUN pnpm docs:build

# ==================== 运行阶段 ====================
FROM caddy:alpine

# 复制构建产物到自定义目录 /docs
# 注意：需要在 Caddyfile 中通过 root * /docs 指定根目录
COPY --from=builder /app/docs/.vitepress/dist /docs

EXPOSE 80
</code></pre>

<h3 id="关键踩坑点">⚠️ 关键踩坑点</h3>

<h4 id="1-git-依赖">1. Git 依赖</h4>

<p>VitePress 1.x 的默认主题会调用 <code>git</code> 命令获取文件的最后更新时间。如果容器中没有 git，构建会报错：</p>

<pre><code class="language-bash">[vitepress] spawn git ENOENT
</code></pre>

<p><strong>解决方案</strong>：在构建阶段安装 git：</p>

<pre><code class="language-bash">RUN apk add --no-cache git
</code></pre>

<h4 id="2-pnpm-供应链安全检查">2. pnpm 供应链安全检查</h4>

<p>pnpm 10.x 默认启用供应链安全检查，拦截发布时间 &lt; 24 小时的包：</p>

<pre><code class="language-bash">[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] @types/node@25.9.2 was published ... within the minimumReleaseAge cutoff
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.21.5, vitepress-theme-demoblock@3.1.3
</code></pre>

<p><strong>解决方案</strong>：使用 <code>--ignore-scripts</code> 参数跳过检查：</p>

<pre><code class="language-bash">RUN pnpm install --frozen-lockfile --ignore-scripts
</code></pre>

<h4 id="3-文件权限">3. 文件权限</h4>

<p>Caddy 官方镜像默认以 <code>caddy</code> 非 root 用户运行（安全考虑）。如果构建产物权限不正确，容器启动后 Caddy 可能无法读取静态文件，导致 <code>403 Forbidden</code>。</p>

<p><strong>解决方案</strong>：在 Caddyfile 中确保 <code>root</code> 目录可读，或使用 <code>--chown</code> 参数：</p>

<pre><code class="language-bash">COPY --from=builder --chown=caddy:caddy /app/docs/.vitepress/dist /docs
</code></pre>

<p>如果你使用自定义目录 <code>/docs</code>，也可以在 Caddyfile 中正常配置 <code>root * /docs</code>，Caddy 会自动处理权限。</p>

<h2 id="第二步-github-actions-工作流">🚀 第二步：GitHub Actions 工作流</h2>

<pre><code class="language-yaml">name: Deploy Docs To Aliyun

on:
  push:
    branches: [main]
    paths:
      - &quot;docs/**&quot;
      - &quot;package.json&quot;
      - &quot;pnpm-lock.yaml&quot;
      - &quot;Dockerfile&quot;
      - &quot;.github/workflows/deploy-docs.yml&quot;

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # ⚙️ Login to ACR 步骤与[入门篇第四步](./docker-quickstart-auto-deploy)完全一致，此处省略。
      # 使用相同的 docker/login-action@v3 + ACR_REGISTRY/ACR_USERNAME/ACR_PASSWORD。

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: Dockerfile
          push: true
          tags: |
            ${{ secrets.ACR_REGISTRY }}/moongate/moongate-vue:latest
            ${{ secrets.ACR_REGISTRY }}/moongate/moongate-vue:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Deploy to Server via SSH
        uses: appleboy/ssh-action@v1.0.0
        env:
          ACR_REGISTRY: ${{ secrets.ACR_REGISTRY }}
          ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
          ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: ACR_REGISTRY, ACR_USERNAME, ACR_PASSWORD
          script: |
            set -e

            # 登录 ACR（与入门篇 ssh-action 相同）
            echo &quot;$ACR_PASSWORD&quot; | docker login &quot;$ACR_REGISTRY&quot; -u &quot;$ACR_USERNAME&quot; --password-stdin

            # ★ 差异化：独立 docker run 部署（不使用 docker-compose）
            # 拉取最新镜像
            docker pull $ACR_REGISTRY/moongate/moongate-vue:latest

            # 强制删除旧容器（避免容器挂起导致的死锁）
            docker rm -f moongate-vue || true

            # 运行新容器（连接到博客的 Docker 网络）
            docker run -d \
              --name moongate-vue \
              --restart unless-stopped \
              --network my-blog_blog-network \
              $ACR_REGISTRY/moongate/moongate-vue:latest

            # 清理旧镜像
            docker image prune -f --filter &quot;until=24h&quot;
</code></pre>

<h3 id="与动态应用-workflow-的差异">与动态应用 workflow 的差异</h3>

<table>
<thead>
<tr>
<th>差异点</th>
<th>Nuxt 动态应用</th>
<th>VitePress 静态站</th>
</tr>
</thead>

<tbody>
<tr>
<td>触发路径</td>
<td>全量触发</td>
<td>仅 <code>docs/**</code> 变更触发</td>
</tr>

<tr>
<td>构建参数</td>
<td>需要 <code>NUXT_PUBLIC_SITE_URL</code></td>
<td>无需</td>
</tr>

<tr>
<td>部署脚本</td>
<td>docker-compose</td>
<td>独立 <code>docker run</code> + <code>--network</code></td>
</tr>

<tr>
<td>环境变量</td>
<td>多个 Secrets 传递</td>
<td>无需</td>
</tr>

<tr>
<td>数据库迁移</td>
<td>有</td>
<td>无</td>
</tr>

<tr>
<td>容器更新</td>
<td><code>docker compose up -d</code></td>
<td><code>docker rm -f</code> + <code>docker run</code></td>
</tr>
</tbody>
</table>

<h2 id="第三步-服务器端配置">🌐 第三步：服务器端配置</h2>

<h3 id="3-1-首次部署-运行容器并连接网络">3.1 首次部署：运行容器并连接网络</h3>

<pre><code class="language-bash"># 拉取镜像
docker pull crpi-xxx/moongate/moongate-vue:latest

# 运行容器（加入博客的网络）
docker run -d \
  --name moongate-vue \
  --restart unless-stopped \
  --network my-blog_blog-network \
  crpi-xxx/moongate/moongate-vue:latest
</code></pre>

<blockquote>
<p><strong>注意</strong>：不需要 <code>-p</code> 端口映射。Caddy 通过 Docker 内部网络直接访问容器，不经过宿主机端口。</p>
</blockquote>

<h3 id="3-2-caddyfile-配置">3.2 Caddyfile 配置</h3>

<p>在 <code>/var/www/my-site/Caddyfile</code> 中添加子域名配置，并指定静态文件根目录：</p>

<pre><code class="language-bash"># 组件库文档站
vue.moongate.top {
    reverse_proxy moongate-vue:80
    encode gzip zstd
}

# 可选：添加 www 重定向
www.vue.moongate.top {
    redir https://vue.moongate.top{uri} permanent
}
</code></pre>

<blockquote>
<p><strong>说明</strong>：</p>

<ul>
<li>网关 Caddy 只负责反向代理，不负责静态文件服务</li>
<li>静态文件服务由文档站容器内的 Caddy 负责</li>
<li>容器间通信使用内网 HTTP，HTTPS 证书由网关 Caddy 统一托管</li>
</ul>
</blockquote>

<h3 id="3-3-重启网关-caddy">3.3 重启网关 Caddy</h3>

<pre><code class="language-bash">cd /var/www/my-site
docker compose restart caddy
</code></pre>

<h3 id="3-4-验证网络连接">3.4 验证网络连接</h3>

<pre><code class="language-bash"># 查看容器网络
docker inspect moongate-vue | grep -A5 &quot;Networks&quot;

# 从网关 Caddy 容器测试连接
docker exec my-blog-caddy wget -qO- http://moongate-vue:80 | head -5
</code></pre>

<h2 id="第四步-dns-配置">🌍 第四步：DNS 配置</h2>

<p>在阿里云 DNS 控制台添加 A 记录：</p>

<table>
<thead>
<tr>
<th>记录类型</th>
<th>主机记录</th>
<th>记录值</th>
</tr>
</thead>

<tbody>
<tr>
<td>A</td>
<td><code>vue</code></td>
<td><code>你的服务器公网IP</code></td>
</tr>
</tbody>
</table>
<p>等待 DNS 生效后，访问 <code>https://vue.moongate.top</code> 即可看到文档站。</p>

<h2 id="第五步-完整配置清单">🔧 第五步：完整配置清单</h2>

<h3 id="github-secrets">GitHub Secrets</h3>

<p><code>SERVER_HOST</code>、<code>SERVER_USER</code>、<code>SSH_PRIVATE_KEY</code> 的配置方法与<a href="./static-site-auto-deploy">静态篇 第二部分</a>一致。本文额外需要：</p>

<table>
<thead>
<tr>
<th>Secret</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>ACR_REGISTRY</code></td>
<td>阿里云 ACR 仓库地址</td>
</tr>

<tr>
<td><code>ACR_USERNAME</code></td>
<td>阿里云用户名</td>
</tr>

<tr>
<td><code>ACR_PASSWORD</code></td>
<td>ACR 固定密码</td>
</tr>
</tbody>
</table>

<h3 id="服务器端命令速查">服务器端命令速查</h3>

<pre><code class="language-bash"># 首次部署：运行容器并连接网络
docker run -d \
  --name moongate-vue \
  --restart unless-stopped \
  --network my-blog_blog-network \
  crpi-xxx/moongate/moongate-vue:latest

# 常用运维命令
docker logs moongate-vue          # 查看日志
docker restart moongate-vue       # 重启容器
docker stop moongate-vue          # 停止容器
docker start moongate-vue         # 启动容器
docker rm -f moongate-vue          # 强制删除容器

# 网络管理
docker network connect my-blog_blog-network moongate-vue  # 连接网络
docker network disconnect my-blog_blog-network moongate-vue  # 断开网络
</code></pre>

<h2 id="踩坑记录">🐛 踩坑记录</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>spawn git ENOENT</code></td>
<td>容器中没有 git</td>
<td><code>RUN apk add --no-cache git</code></td>
</tr>

<tr>
<td><code>ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION</code></td>
<td>pnpm 供应链安全检查</td>
<td><code>--ignore-scripts</code></td>
</tr>

<tr>
<td><code>ERR_PNPM_IGNORED_BUILDS</code></td>
<td>构建脚本被忽略</td>
<td>同上</td>
</tr>

<tr>
<td>Caddy 返回 403</td>
<td>静态文件权限不足</td>
<td>使用 <code>--chown=caddy:caddy</code> 或检查目录权限</td>
</tr>

<tr>
<td>Caddy 返回 502</td>
<td>文档站容器未连接到 Caddy 的网络</td>
<td><code>docker network connect</code></td>
</tr>

<tr>
<td>域名无法访问</td>
<td>DNS 未配置</td>
<td>添加 A 记录</td>
</tr>

<tr>
<td>Caddy 无法解析 <code>moongate-vue</code> 主机名</td>
<td>容器不在同一网络</td>
<td>使用 <code>--network</code> 参数运行</td>
</tr>

<tr>
<td>CI 部署时容器名冲突</td>
<td>旧容器未完全清理</td>
<td>使用 <code>docker rm -f</code> 强制删除</td>
</tr>
</tbody>
</table>

<h2 id="部署流程图">📈 部署流程图</h2>

<pre><code class="language-text">┌─────────────────────────────────────────────────────────────────────┐
│                           开发者本地                                 │
│  $ git add docs/                                                     │
│  $ git commit -m &quot;update docs&quot;                                       │
│  $ git push origin main                                              │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                        GitHub Actions                                │
│  1. 检出代码                                                         │
│  2. 安装 pnpm、依赖                                                   │
│  3. 执行 pnpm docs:build                                             │
│  4. 构建 Docker 镜像                                                 │
│  5. 推送到阿里云 ACR                                                  │
│  6. SSH 到服务器                                                      │
│  7. docker pull &amp;&amp; docker rm -f &amp;&amp; docker run --network ...         │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                          阿里云 ECS                                   │
│  1. 拉取最新镜像                                                      │
│  2. 强制删除旧容器                                                    │
│  3. 启动新容器（加入博客网络）                                         │
│  4. 网关 Caddy 代理 vue.moongate.top → moongate-vue:80               │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                           用户访问                                    │
│                    https://vue.moongate.top                          │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>

<h2 id="与动态应用部署的优劣对比">📊 与动态应用部署的优劣对比</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>VitePress 静态站</th>
<th>Nuxt 动态应用</th>
</tr>
</thead>

<tbody>
<tr>
<td>部署复杂度</td>
<td>⭐ 极低</td>
<td>⭐⭐⭐⭐ 较高</td>
</tr>

<tr>
<td>构建时间</td>
<td>~30 秒</td>
<td>~2 分钟</td>
</tr>

<tr>
<td>镜像体积</td>
<td>~50 MB</td>
<td>~200 MB</td>
</tr>

<tr>
<td>运行时内存</td>
<td>~20 MB</td>
<td>~150 MB</td>
</tr>

<tr>
<td>启动时间</td>
<td>&lt;1 秒</td>
<td>~5 秒</td>
</tr>

<tr>
<td>端口配置</td>
<td>无需暴露端口</td>
<td>需要端口映射</td>
</tr>

<tr>
<td>网络配置</td>
<td>需连接到 Caddy 网络</td>
<td>compose 自动管理</td>
</tr>

<tr>
<td>容器管理</td>
<td>独立 <code>docker run</code></td>
<td>docker-compose 编排</td>
</tr>

<tr>
<td>可维护性</td>
<td>极高</td>
<td>中等</td>
</tr>
</tbody>
</table>

<h2 id="总结">🎉 总结</h2>

<p>VitePress 文档站接入已有 Docker 基础设施的关键步骤：</p>

<ol>
<li><strong>Dockerfile</strong>：使用多阶段构建 + Caddy 静态服务器，注意：

<ul>
<li>git 依赖（VitePress 需要）</li>
<li>pnpm 供应链安全检查（<code>--ignore-scripts</code>）</li>
<li>文件权限（可选 <code>--chown=caddy:caddy</code>）</li>
</ul></li>
<li><strong>镜像推送</strong>：推送到阿里云 ACR，利用 GitHub Actions 缓存加速</li>
<li><strong>容器部署</strong>：使用 <code>docker rm -f</code> 强制替换，<code>--network</code> 接入现有网络</li>
<li><strong>Caddy 代理</strong>：在网关 Caddyfile 中添加子域名配置，指定 <code>root * /docs</code></li>
<li><strong>DNS 解析</strong>：为子域名添加 A 记录</li>
</ol>

<h3 id="架构亮点">架构亮点</h3>

<ul>
<li>双 Caddy 架构：网关 Caddy 处理 HTTPS 和路由，容器内 Caddy 仅服务静态文件</li>
<li>内网通信：容器间通过 Docker 内部网络通信，无需暴露端口到宿主机</li>
<li>统一证书管理：HTTPS 证书由网关 Caddy 统一托管，子域名自动继承</li>
<li>系列风格统一：沿用自定义目录 <code>/docs</code>，与之前教程保持一致</li>
</ul>

<p>与动态应用不同，静态站部署更简单、资源占用更少。本文完整记录了接入已有 Docker 基础设施的全过程，这套流程同样适用于任何静态站点生成器（如 Hexo、Hugo、Astro 等），只需调整构建命令和静态文件输出目录即可。</p>
]]></content:encoded>
      <description><![CDATA[本文记录如何将 VitePress 文档站部署为子域名，并接入已有 docker-compose 管理的动态站点（如 Nuxt 博客），共用同一 Caddy 反向代理。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[VitePress + vitepress-theme-demoblock 3.x 配置指南]]></title>
      <link>https://moongate.top/docs/demoblock-3x-missing-manual</link>
      <guid isPermaLink="true">https://moongate.top/docs/demoblock-3x-missing-manual</guid>
      <pubDate>Wed, 03 Jun 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文基于 <code>vitepress-theme-demoblock@3.x</code> 版本，详细解释每一步的作用和背后的原理。</p>
</blockquote>

<h2 id="背景">背景</h2>

<p>为 Vue 3 组件库搭建文档站，需要支持：</p>

<ul>
<li>组件实时预览</li>
<li>代码展示 + 折叠</li>
<li>一键复制代码</li>
</ul>

<p>官方推荐 <code>vitepress-theme-demoblock</code>，但配置过程中发现：<strong>官方文档虽然写全了步骤，但没有解释「为什么」，导致容易踩坑。</strong></p>

<p>本文补充官方缺失的「连接逻辑」。</p>

<h2 id="官方文档写了什么">官方文档写了什么</h2>

<p>根据<a href="https://github.com/xinlei3166/vitepress-theme-demoblock" target="_blank">官方文档</a>，配置步骤如下：</p>

<h3 id="1-安装">1. 安装</h3>

<pre><code class="language-bash">pnpm add -D vitepress-theme-demoblock
</code></pre>

<h3 id="2-配置-config-ts">2. 配置 config.ts</h3>

<pre><code class="language-ts">import {
  demoblockPlugin,
  demoblockVitePlugin,
} from &quot;vitepress-theme-demoblock&quot;;

export default {
  markdown: {
    config: (md) =&gt; {
      md.use(demoblockPlugin);
    },
  },
  vite: {
    plugins: [demoblockVitePlugin()],
  },
};
</code></pre>

<h3 id="3-配置主题">3. 配置主题</h3>

<pre><code class="language-ts">import DefaultTheme from &quot;vitepress/theme&quot;;
import &quot;vitepress-theme-demoblock/dist/theme/styles/index.css&quot;;
import { useComponents } from &quot;./useComponents&quot;; // ← 从本地导入

export default {
  ...DefaultTheme,
  enhanceApp(ctx) {
    DefaultTheme.enhanceApp(ctx);
    useComponents(ctx.app);
  },
};
</code></pre>

<h3 id="4-添加脚本">4. 添加脚本</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;docs:dev&quot;: &quot;yarn run register:components &amp;&amp; vitepress dev docs&quot;,
    &quot;register:components&quot;: &quot;vitepress-rc&quot;
  }
}
</code></pre>

<h2 id="官方没说的是什么">官方没说的是什么</h2>

<h3 id="问题一-usecomponents-从哪来">问题一：<code>useComponents</code> 从哪来？</h3>

<p>官方写的是 <code>import { useComponents } from './useComponents'</code>，但：</p>

<ul>
<li>项目中一开始并没有这个文件</li>
<li>官方没说明这个文件是<strong>生成的</strong></li>
</ul>

<p><strong>答案</strong>：</p>

<p><code>vitepress-rc</code> 命令会生成这个文件。</p>

<h3 id="问题二-vitepress-rc-是做什么的">问题二：<code>vitepress-rc</code> 是做什么的？</h3>

<p>官方只是列出了一个命令，没说明作用。</p>

<p><strong>答案</strong>：</p>

<p><code>vitepress-rc</code> 会：</p>

<ol>
<li>扫描你的组件目录（默认读取 <code>docs/.vitepress/components</code> 或项目根目录的 <code>components.json</code>）</li>
<li>自动注册 demoblock 需要的 Vue 组件</li>
<li>生成 <code>.vitepress/theme/useComponents.ts</code> 文件</li>
</ol>

<blockquote>
<p><strong>注意</strong>：如果你的组件库在 <code>src/components</code> 目录下，需要配置 <code>vitepress-rc</code> 的扫描路径，否则会生成空文件。</p>
</blockquote>

<h3 id="问题三-为什么要先运行-register-components">问题三：为什么要先运行 <code>register:components</code>？</h3>

<p>官方脚本写了 <code>&amp;&amp; vitepress dev docs</code>，但没解释为什么要先运行。</p>

<p><strong>答案</strong>：因为 VitePress 启动时需要读取 <code>./useComponents.ts</code>，而这个文件必须先由 <code>vitepress-rc</code> 生成。</p>

<h3 id="问题四-不运行会怎样">问题四：不运行会怎样？</h3>

<p>直接运行 <code>vitepress dev docs</code> 会报错：</p>

<pre><code class="language-bash">Uncaught SyntaxError: Cannot find module './useComponents'
</code></pre>

<h2 id="完整配置流程-含解释">完整配置流程（含解释）</h2>

<h3 id="第一步-安装依赖">第一步：安装依赖</h3>

<pre><code class="language-bash">pnpm add -D vitepress vitepress-theme-demoblock
</code></pre>

<h3 id="第二步-配置-config-ts">第二步：配置 config.ts</h3>

<pre><code class="language-ts">// docs/.vitepress/config.ts
import { defineConfig } from &quot;vitepress&quot;;
import {
  demoblockPlugin,
  demoblockVitePlugin,
} from &quot;vitepress-theme-demoblock&quot;;

export default defineConfig({
  title: &quot;My UI&quot;,
  description: &quot;Vue 3 组件库&quot;,

  markdown: {
    config: (md) =&gt; {
      md.use(demoblockPlugin); // Markdown-it 插件：解析 :::demo 语法
    },
  },

  vite: {
    plugins: [demoblockVitePlugin()], // Vite 插件：编译 demo 中的 Vue 组件
  },
});
</code></pre>

<blockquote>
<p><strong>💡 原理解析</strong>：两个插件的分工</p>

<ul>
<li><code>demoblockPlugin</code>（Markdown-it 插件）：负责「偷梁换柱」。把 Markdown 里的 <code>:::demo</code> 语法转换成自定义的 <code>&lt;demo-block&gt;</code> 标签，将代码作为字符串和实时运行的组件传入。</li>
<li><code>demoblockVitePlugin</code>（Vite 插件）：负责「降维打击」。在编译阶段把抽离出来的虚拟 Vue 代码真正编译成浏览器可执行的 JS/Vue 组件。</li>
</ul>
</blockquote>

<h3 id="第三步-配置主题">第三步：配置主题</h3>

<pre><code class="language-ts">// docs/.vitepress/theme/index.ts
import DefaultTheme from &quot;vitepress/theme&quot;;
import &quot;vitepress-theme-demoblock/dist/theme/styles/index.css&quot;;
// 注意：从本地导入，不是从包导入
import { useComponents } from &quot;./useComponents&quot;;

export default {
  ...DefaultTheme,
  enhanceApp(ctx) {
    DefaultTheme.enhanceApp(ctx);
    useComponents(ctx.app); // 注册 demoblock 组件
  },
};
</code></pre>

<h3 id="第四步-添加脚本">第四步：添加脚本</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;docs:dev&quot;: &quot;pnpm run register:components &amp;&amp; vitepress dev docs&quot;,
    &quot;docs:build&quot;: &quot;pnpm run register:components &amp;&amp; vitepress build docs&quot;,
    &quot;register:components&quot;: &quot;vitepress-rc&quot;
  }
}
</code></pre>

<h4 id="脚本说明">脚本说明</h4>

<table>
<thead>
<tr>
<th>脚本</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>vitepress-rc</code></td>
<td>生成 <code>./useComponents.ts</code> 文件</td>
</tr>

<tr>
<td><code>register:components</code></td>
<td>封装注册命令</td>
</tr>

<tr>
<td><code>docs:dev</code></td>
<td>先注册，再启动开发服务器</td>
</tr>
</tbody>
</table>

<h3 id="第五步-首次运行">第五步：首次运行</h3>

<pre><code class="language-bash">pnpm docs:dev
</code></pre>

<p>执行过程：</p>

<ol>
<li>运行 <code>vitepress-rc</code> → 生成 <code>useComponents.ts</code></li>
<li>启动 VitePress → 读取生成的 <code>useComponents.ts</code></li>
<li>文档站正常运行</li>
</ol>

<h3 id="第六步-编写文档">第六步：编写文档</h3>

<h4 id="v3-正确写法">v3 正确写法</h4>

<pre><code class="language-md">## 基础用法

这是按钮的基础用法描述。（直接写在 `:::demo` 上方）

:::demo
\`\`\`vue
&lt;template&gt;
&lt;Button type=&quot;primary&quot;&gt;主要按钮&lt;/Button&gt;
&lt;/template&gt;

&lt;script setup&gt;
import { Button } from 'my-ui'
&lt;/script&gt;

\`\`\`
:::
</code></pre>

<h2 id="官方文档-vs-本文补充">官方文档 vs 本文补充</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>官方文档</th>
<th>本文补充</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>useComponents</code> 从哪来</td>
<td>只写了导入语句</td>
<td>说明是 <code>vitepress-rc</code> 生成</td>
</tr>

<tr>
<td><code>vitepress-rc</code> 的作用</td>
<td>只写了命令</td>
<td>解释作用和生成的文件</td>
</tr>

<tr>
<td>命令执行顺序</td>
<td>写了 <code>&amp;&amp;</code></td>
<td>解释为什么要先执行</td>
</tr>

<tr>
<td>不执行的后果</td>
<td>没写</td>
<td>说明会报错</td>
</tr>

<tr>
<td>生成的文件</td>
<td>没提</td>
<td>讨论 Git 提交策略</td>
</tr>

<tr>
<td>两个插件的分工</td>
<td>没提</td>
<td>解释原理</td>
</tr>
</tbody>
</table>

<h2 id="常见问题">常见问题</h2>

<h3 id="q1-vitepress-rc-生成的文件要提交到-git-吗">Q1：<code>vitepress-rc</code> 生成的文件要提交到 Git 吗？</h3>

<p><strong>建议：提交 <code>useComponents.ts</code>，忽略 <code>cache/</code></strong></p>

<pre><code class="language-gitignore"># .gitignore
docs/.vitepress/cache/
# 注意：不要忽略 useComponents.ts
</code></pre>

<h4 id="理由">理由</h4>

<ul>
<li><code>useComponents.ts</code> 内容稳定（只在组件列表变化时改变），体积很小（几 KB）</li>
<li>提交后团队成员 <code>git clone</code> 即可直接运行 <code>pnpm docs:dev</code>，无需额外生成</li>
<li>CI/CD 构建时也不需要重新运行 <code>vitepress-rc</code>，减少构建步骤</li>
</ul>

<h4 id="如果不提交">如果不提交</h4>

<p>可以使用 <code>prepare</code> 钩子在 <code>pnpm install</code> 时自动生成：</p>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;prepare&quot;: &quot;pnpm run register:components&quot;
  }
}
</code></pre>

<p>但需要注意每次 <code>install</code> 都会重新生成，<code>cache/deps</code> 也会更新，可能导致 Git 状态变化。</p>

<h3 id="q2-更新依赖后需要重新运行吗">Q2：更新依赖后需要重新运行吗？</h3>

<p>建议重新运行一次：</p>

<pre><code class="language-bash">pnpm run register:components
</code></pre>

<h3 id="q3-demo-后面的描述文字不生效">Q3：<code>:::demo</code> 后面的描述文字不生效？</h3>

<p>3.x 版本<strong>不支持</strong>描述文字：</p>

<table>
<thead>
<tr>
<th>版本</th>
<th>正确写法</th>
</tr>
</thead>

<tbody>
<tr>
<td>v2</td>
<td><code>:::demo 这是描述</code></td>
</tr>

<tr>
<td>v3</td>
<td><code>:::demo</code>（不支持描述文字）</td>
</tr>
</tbody>
</table>

<h4 id="平替方案">平替方案</h4>

<p>在 <code>:::demo</code> 上方直接写标准 Markdown 文本即可。</p>

<pre><code class="language-md">### 基础用法

这是按钮的基础用法描述。

:::demo
\`\`\`vue
&lt;template&gt;
&lt;Button type=&quot;primary&quot;&gt;主要按钮&lt;/Button&gt;
&lt;/template&gt;
\`\`\`
:::
</code></pre>

<h3 id="q4-vitepress-rc-扫描不到我的组件怎么办">Q4：<code>vitepress-rc</code> 扫描不到我的组件怎么办？</h3>

<p><code>vitepress-rc</code> 默认读取 <code>docs/.vitepress/components</code> 目录或项目根目录的 <code>components.json</code> 配置。</p>

<p>如果你的组件库在 <code>src/components</code> 目录下，有两种解决方案：</p>

<h4 id="方案-a">方案 A</h4>

<p>创建软链接</p>

<pre><code class="language-bash">mkdir -p docs/.vitepress/components
ln -s ../../../src/components docs/.vitepress/components/ui
</code></pre>

<h4 id="方案-b">方案 B</h4>

<p>创建 <code>components.json</code></p>

<pre><code class="language-json">{
  &quot;componentsDir&quot;: &quot;./src/components&quot;
}
</code></pre>

<h2 id="总结">总结</h2>

<p>官方文档<strong>信息是全的</strong>，但问题是<strong>缺少「连接逻辑」</strong>：</p>

<ul>
<li>写了 <code>import from './useComponents'</code>，没写这个文件从哪来</li>
<li>写了 <code>vitepress-rc</code> 命令，没写它的作用</li>
<li>写了 <code>&amp;&amp;</code> 串联命令，没写为什么要先执行</li>
</ul>

<h3 id="核心要点">核心要点</h3>

<ol>
<li><code>vitepress-rc</code> 会生成 <code>useComponents.ts</code> 文件</li>
<li>必须先运行 <code>vitepress-rc</code>，再启动 VitePress</li>
<li>3.x 版本不支持 <code>:::demo</code> 后面的描述文字，需要用普通 Markdown 代替</li>
<li>从本地导入 <code>useComponents</code>，不是从包导入</li>
<li><code>demoblockPlugin</code>（Markdown-it）负责解析语法，<code>demoblockVitePlugin</code>（Vite）负责编译组件</li>
</ol>

<p>希望这篇文章能帮你理解 demoblock 的配置原理，少踩坑。</p>
]]></content:encoded>
      <description><![CDATA[详解 demoblock 3.x 的配置原理、踩坑记录和最佳实践，补充官方文档缺失的「连接逻辑」。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[从代码到 npm：Vue 3 组件库发布实战与避坑指南]]></title>
      <link>https://moongate.top/docs/component-library-publishing</link>
      <guid isPermaLink="true">https://moongate.top/docs/component-library-publishing</guid>
      <pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>组件库写完了，发布到 npm 才是真正的考验：2FA、网络代理、源切换、构建产物校验……这篇把踩过的坑和最终标准化的发布流程一次讲清。</p>
</blockquote>

<h2 id="一-引言">一、引言</h2>

<p>组件库开发完成后，最后一步也是至关重要的一步：<strong>发布到 npm</strong>。这个过程看似简单，实则暗藏不少现代工程包袱：包名冲突、2FA 强校验、安全密钥（WebAuthn）在特定网络下的卡死、源镜像频繁切换……以及——<strong>构建产物的正确性</strong>。</p>

<p>本文记录了我从 <strong>v0.0.1</strong> 一路到 <strong>v1.5.0</strong> 的发布实战。</p>

<h2 id="二-发布前的准备">二、发布前的准备</h2>

<h3 id="2-1-构建与验证链路-v1-5-0-实际">2.1 构建与验证链路（v1.5.0 实际）</h3>

<p>v1.5.0 的 <code>build</code> 脚本是一条自动化验证流水线：</p>

<pre><code class="language-bash"># package.json scripts
&quot;build&quot;: &quot;pnpm run clean &amp;&amp; vite build &amp;&amp; pnpm run build:types &amp;&amp; pnpm run clean:dts &amp;&amp; pnpm run copy:reset &amp;&amp; pnpm run verify:build &amp;&amp; pnpm run check:size&quot;,
&quot;prepublishOnly&quot;: &quot;pnpm build &amp;&amp; pnpm test&quot;
</code></pre>

<p>每一步的职责：</p>

<table>
<thead>
<tr>
<th>脚本</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>vite build</code></td>
<td>打包 JS + CSS 产物（27 组件多入口）</td>
</tr>

<tr>
<td><code>build:types</code></td>
<td>vue-tsc 生成 <code>.d.ts</code> 类型声明</td>
</tr>

<tr>
<td><code>clean:dts</code></td>
<td>清理产物中的冗余类型文件</td>
</tr>

<tr>
<td><code>copy:reset</code></td>
<td>复制 <code>reset.css</code> 到 dist</td>
</tr>

<tr>
<td><code>verify:build</code></td>
<td>验证<strong>每个组件入口</strong>的 .js / .d.ts / style.css / reset.css 存在 + 导出名正确</td>
</tr>

<tr>
<td><code>check:size</code></td>
<td>体积预算检查（Min+Gzip ≤ 25KB）</td>
</tr>

<tr>
<td><code>prepublishOnly</code></td>
<td>发布前强制 build + test</td>
</tr>
</tbody>
</table>
<p><code>verify-build.js</code> 的核心逻辑——防止漏发布某个组件入口：</p>

<pre><code class="language-javascript">// 每个组件：dist/&lt;kebab&gt;.js 存在 + dist/exports/&lt;Name&gt;.d.ts 存在 + 导出名正确
for (const [componentName, kebabName] of Object.entries(componentEntries)) {
  const jsPath = join(distDir, `${kebabName}.js`)
  const dtsPath = join(distDir, &quot;exports&quot;, `${componentName}.d.ts`)
  if (!existsSync(jsPath)) {
    errors.push(`缺少组件产物: ${kebabName}.js`)
  }
  // 任一错误 → process.exit(1) 阻止发布
}
</code></pre>

<h3 id="2-2-分发契约-package-json">2.2 分发契约（package.json）</h3>

<pre><code class="language-json">{
  &quot;name&quot;: &quot;moongate-vue&quot;,
  &quot;version&quot;: &quot;1.5.0&quot;,
  &quot;type&quot;: &quot;module&quot;,
  &quot;main&quot;: &quot;./dist/index.js&quot;,
  &quot;module&quot;: &quot;./dist/index.js&quot;,
  &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/index.js&quot;,
      &quot;default&quot;: &quot;./dist/index.js&quot;
    },
    &quot;./style.css&quot;: &quot;./dist/style.css&quot;,
    &quot;./reset.css&quot;: &quot;./dist/reset.css&quot;,
    &quot;./button&quot;: {
      &quot;types&quot;: &quot;./dist/exports/Button.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/button.js&quot;,
      &quot;default&quot;: &quot;./dist/button.js&quot;
    }
    // ... 27 个组件入口
  },
  &quot;files&quot;: [&quot;dist&quot;],
  &quot;sideEffects&quot;: [&quot;*.css&quot;],
  &quot;peerDependencies&quot;: {
    &quot;vue&quot;: &quot;^3.5.0&quot;
  }
}
</code></pre>

<h4 id="关键差异-vs-初版">关键差异（vs 初版）</h4>

<ul>
<li><strong>纯 ES Module</strong>：只有 <code>.js</code>，没有 <code>.cjs</code>——这是刻意选择（现代打包工具均支持 ESM，且 <code>exports</code> 中有 <code>import</code> 条件即可）</li>
<li><strong>27 个按需导出入口</strong>：用户可 <code>import Button from 'moongate-vue/button'</code> 只加载所需组件</li>
<li><strong>peerDependencies 升到 <code>^3.5.0</code></strong>：因为用了 <code>useId()</code>（Vue 3.5+ API）</li>
<li><code>sideEffects: [&quot;*.css&quot;]</code>：防止打包器 tree-shake 误删 CSS</li>
</ul>

<blockquote>
<p><strong>💡 包体积防御提示</strong>：<code>files</code> 字段是&rdquo;白名单&rdquo;。配置 <code>[&quot;dist&quot;]</code> 后，npm 只上传 <code>dist</code> 目录。<code>package.json</code>、<code>README.md</code>、<code>LICENSE</code> 会被 npm 强制包含。</p>
</blockquote>

<h3 id="2-3-使用-nrm-管理-npm-源">2.3 使用 nrm 管理 npm 源</h3>

<details>
<summary>🛠️ nrm 源切换细节（点击展开）</summary>

发布到 npm 必须使用官方源。如果你之前为了加速下载切换到了国内镜像，推荐使用 `nrm`。

<pre><code class="language-bash">npm install -g nrm
nrm ls
nrm use npm        # 发布时切换到官方源
nrm current</code></pre>

> **提示**：国内淘宝 npm 镜像已迁移至 `https://registry.npmmirror.com`。

</details>

<h3 id="2-4-本地集成测试">2.4 本地集成测试</h3>

<p>在发布到 npm 之前，最好在真实项目中先测试一遍：</p>

<pre><code class="language-bash"># 1. 在组件库目录：构建 + 全局链接
pnpm build
pnpm link --global

# 2. 在测试项目目录：链接本地组件库
pnpm link /home/dark/projects/moongate-vue
</code></pre>

<p>测试清单：</p>

<ul>
<li>[ ] 组件能正常渲染</li>
<li>[ ] 样式文件导入生效（<code>import 'moongate-vue/style.css'</code>）</li>
<li>[ ] 按需入口生效（<code>import Button from 'moongate-vue/button'</code>）</li>
<li>[ ] TypeScript 类型提示正常</li>
<li>[ ] HMR 热更新工作正常</li>
</ul>

<h4 id="注意">注意</h4>

<p>测试完成后，删除链接需：<code>pnpm remove moongate-vue</code> + 手动清理 <code>link:</code> 条目。</p>

<hr>

<h2 id="三-攻克双重认证-2fa-泥潭">三、攻克双重认证（2FA）泥潭</h2>

<p>npm 强制要求发布时开启双重认证。npm 已全面拥抱 <strong>安全密钥 (WebAuthn)</strong> 模式。</p>

<details>
<summary>🛠️ WebAuthn 网络排障细节（点击展开）</summary>

> **⚠️ 工业级避坑警告**：npm 的 WebAuthn 验证会尝试与 Google 验证服务联动。在国内网络环境下，使用 Chrome/Edge 弹出密钥窗口时**极易由于网络超时而无响应或报错**。

| 浏览器      | 是否需要全局代理 | 成功率 | 建议       |
| ----------- | ---------------- | ------ | ---------- |
| **Chrome**  | ✅ 必须开启      | 极高   | **首选**   |
| **Edge**    | ❌ 不需要        | 高     | 次选       |
| **Firefox** | ❌ 不需要        | 极低   | **不建议** |

</details>

<h3 id="3-2-ci-cd-备选-granular-access-token">3.2 CI/CD 备选：Granular Access Token</h3>

<pre><code class="language-bash">//registry.npmjs.org/:_authToken=你的_granular_token_值
</code></pre>

<p>权限勾选 <strong>Read and Write</strong>，并勾选 <strong>&ldquo;Bypass two-factor authentication for automation&rdquo;</strong>。</p>

<hr>

<h2 id="四-标准化发布流程">四、标准化发布流程</h2>

<h3 id="4-1-手动发布步骤">4.1 手动发布步骤</h3>

<ol>
<li><code>nrm use npm</code></li>
<li><code>npm login</code>（浏览器完成 2FA）</li>
<li><code>npm version patch|minor|major</code>（注意 <code>--no-git-tag-version</code> 可选）</li>
<li><code>npm publish --access public</code></li>
</ol>

<blockquote>
<p>作用域包必须加 <code>--access public</code>。</p>
</blockquote>

<h3 id="4-2-分步式自动化脚本">4.2 分步式自动化脚本</h3>

<pre><code class="language-json">{
  &quot;scripts&quot;: {
    &quot;release:pre&quot;: &quot;nrm use npm &amp;&amp; npm run build &amp;&amp; npm test&quot;,
    &quot;release:version&quot;: &quot;npm version patch --no-git-tag-version&quot;,
    &quot;release:tag&quot;: &quot;git add package.json &amp;&amp; git commit -m \&quot;chore: release v$(node -p 'require(\&quot;./package.json\&quot;).version')\&quot; &amp;&amp; git tag v$(node -p 'require(\&quot;./package.json\&quot;).version')&quot;,
    &quot;release:publish&quot;: &quot;npm publish --access public&quot;,
    &quot;release&quot;: &quot;npm run release:pre &amp;&amp; npm run release:version &amp;&amp; npm run release:tag &amp;&amp; npm run release:publish&quot;
  }
}
</code></pre>

<h2 id="五-发布前最终检查清单">五、发布前最终检查清单</h2>

<ul>
<li>[ ] <strong>构建无误</strong>：<code>pnpm run build</code> 未报错（含 verify-build + check:size）</li>
<li>[ ] <strong>产物完整</strong>：<code>dist/</code> 有 index.js、27 个组件 .js、style.css、reset.css、index.d.ts</li>
<li>[ ] <strong>体积达标</strong>：Min+Gzip ≤ 25KB（tree-shake-check.js 输出确认）</li>
<li>[ ] <strong>测试通过</strong>：<code>pnpm test</code>（450 个测试全绿）</li>
<li>[ ] <strong>版本干净</strong>：当前版本号从未在 npm 上存在过</li>
<li>[ ] <strong>本地沙盒</strong>：样式 + 按需入口 + 类型均正常</li>
<li>[ ] <strong>peerDependencies 正确</strong>：vue ^3.5.0</li>
</ul>

<hr>

<h2 id="六-faq">六、FAQ</h2>

<h3 id="q1-发布返回-403-404">Q1: 发布返回 403/404？</h3>

<p>403 = 包名被占用 或 未登录。404 = 忘了 <code>nrm use npm</code> 发布到只读镜像。</p>

<h3 id="q2-2fa-弹不出安全密钥窗口">Q2: 2FA 弹不出安全密钥窗口？</h3>

<p>确认代理开启全局/TUN 模式。仍失败则用 Granular Access Token。</p>

<h3 id="q3-用户安装后样式白屏">Q3: 用户安装后样式白屏？</h3>

<p>确认 <code>sideEffects: [&quot;*.css&quot;]</code> 已配置，且用户显式 <code>import 'moongate-vue/style.css'</code>。</p>

<h3 id="q4-用户能-import-moongate-vue-但-import-moongate-vue-button-失败">Q4: 用户能 import &lsquo;moongate-vue&rsquo; 但 import &lsquo;moongate-vue/button&rsquo; 失败？</h3>

<p>检查 <code>exports</code> 中是否声明了 <code>/button</code> 子路径。v1.5.0 已为 27 个组件全部配置。</p>

<hr>

<h2 id="七-结语">七、结语</h2>

<p>发布环节经常被当作&rdquo;最后一步的杂事&rdquo;，但它其实和组件设计同等重要——<strong>构建产物的正确性、体积的克制、API 的稳定</strong>，都靠这一环节守住。</p>

<p>愿你的组件库也能跨越泥潭，抵达更远的远方。🚀</p>

<hr>

<h2 id="关于-moongate-vue">🌙 关于 Moongate Vue</h2>

<p>本文基于 <a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">Moongate Vue</a> 的真实发布实践，相关资源：</p>

<ul>
<li><strong>项目仓库</strong>：<a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">github.com/yuelinghuashu/moongate-vue</a> — 极简 Vue 3 组件库，零依赖、CSS 优先、25KB gzip</li>
<li><strong>真实案例</strong>：<a href="https://moongate.top" target="_blank">moongate.top</a> — 个人博客，从 Nuxt UI v4 迁移至 Moongate Vue 构建</li>
<li><strong>在线文档</strong>：<a href="https://vue.moongate.top" target="_blank">vue.moongate.top</a> — 组件 API 与主题定制指南</li>
</ul>
]]></content:encoded>
      <description><![CDATA[记录 moongate-vue 组件库从构建到 npm v1.5.0 发布的完整流程，涵盖 nrm 源管理、2FA 配置、WebAuthn 网络代理、本地链接测试、构建验证、体积预算、自动化脚本及工业级发布检查清单。]]></description>
      <category><![CDATA[CI/CD]]></category>
      <category><![CDATA[Security]]></category>
      <category><![CDATA[Engineering]]></category>
      
    </item>

    <item>
      <title><![CDATA[Vue 3 复杂组件开发实战：Select 与 Pagination 的 API 设计与状态管理]]></title>
      <link>https://moongate.top/docs/complex-component-api-design</link>
      <guid isPermaLink="true">https://moongate.top/docs/complex-component-api-design</guid>
      <pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>简单组件是单向的数据消费者，复杂组件是数据适配器与状态协调器。以 Select 和 Pagination 为例，看&rdquo;工业级细节&rdquo;落在哪里。</p>
</blockquote>

<h2 id="一-引言">一、引言</h2>

<p>如果说写 Button 组件是在享受写 CSS 变量的&rdquo;涂料之美&rdquo;，那么写 Select 和 Pagination 就是在应对原生 HTML 历史包袱的&rdquo;泥潭摔跤&rdquo;。简单组件是单向的数据消费者，而复杂组件则是<strong>数据适配器</strong>（兼容多格式）与<strong>状态协调器</strong>（可搜索、多选、键盘导航、SSR 安全）。</p>

<p>本文以 <strong>Select</strong> 和 <strong>Pagination</strong> 为例，展示 v1.5.0 实际实现的复杂组件开发思路。</p>

<h2 id="二-select-下拉选择框-数据适配器">二、Select 下拉选择框：数据适配器</h2>

<p>Select 组件需要接收一组选项，并允许用户选择其中一个。真实世界的 API 可能返回对象数组、字符串数组甚至数字数组，因此组件必须具备强大的数据适配能力。</p>

<h3 id="2-1-需求分析">2.1 需求分析</h3>

<ul>
<li>支持对象数组 <code>{ label, value }</code>（默认）</li>
<li>支持自定义字段名（<code>labelKey</code> / <code>valueKey</code>）</li>
<li>支持字符串数组 <code>['选项A', '选项B']</code></li>
<li>支持数字数组 <code>[1, 2, 3]</code></li>
<li>提供占位符（不可选中的默认选项）</li>
<li>支持禁用选项（<code>disabled: true</code>）</li>
<li><strong>可搜索模式</strong>（filterable）：输入过滤 + 下拉面板</li>
<li><strong>多选模式</strong>（multiple + filterable）：标签展示</li>
<li><strong>必须解决原生 <code>&lt;select&gt;</code> 返回字符串的类型陷阱</strong></li>
<li><strong>完整 ARIA 键盘导航</strong>（listbox + option + aria-activedescendant）</li>
</ul>

<h3 id="2-2-双模式架构-原生模式-可搜索模式">2.2 双模式架构：原生模式 + 可搜索模式</h3>

<p>v1.5.0 的 Select 支持<strong>双模式</strong>：</p>

<ul>
<li><strong>原生模式</strong>（默认）：渲染原生 <code>&lt;select&gt;</code>，零 JS 开销</li>
<li><strong>可搜索模式</strong>（<code>filterable=true</code>）：渲染自定义输入框 + 下拉面板，支持搜索/多选/键盘导航</li>
</ul>

<pre><code class="language-vue">&lt;!-- 原生模式：性能最优 --&gt;
&lt;Select v-model=&quot;category&quot; :options=&quot;categories&quot; /&gt;

&lt;!-- 可搜索模式：过滤 + 下拉 --&gt;
&lt;Select v-model=&quot;fruit&quot; :options=&quot;fruits&quot; filterable /&gt;

&lt;!-- 可搜索 + 多选 --&gt;
&lt;Select v-model=&quot;tags&quot; :options=&quot;tags&quot; filterable multiple /&gt;
</code></pre>

<h3 id="2-3-api-设计与类型防腐">2.3 API 设计与类型防腐</h3>

<p>为了避免 <code>any</code> 造成类型污染，我们采用联合类型收窄：</p>

<pre><code class="language-typescript">export type SelectValue = string | number
export type SelectOption = string | number | Record&lt;string, any&gt;

interface Props {
  options?: SelectOption[]
  placeholder?: string
  size?: Size
  disabled?: boolean
  error?: boolean
  labelKey?: string // 默认 'label'
  valueKey?: string // 默认 'value'
  filterable?: boolean // 可搜索模式
  emptyText?: string
  maxHeight?: number // 下拉面板最大高度
  multiple?: boolean // 多选（需 filterable）
}
</code></pre>

<p><strong>类型回溯</strong>解决原生 select 总是返回字符串的问题：</p>

<pre><code class="language-typescript">const handleNativeChange = (event: Event) =&gt; {
  const target = event.target as HTMLSelectElement
  const rawValue = target.value

  // 在原始 options 中找回原始类型（数字或对象值）
  const originalItem = props.options?.find(
    (item) =&gt; String(getValue(item)) === rawValue,
  )
  const finalValue =
    originalItem !== undefined ? getValue(originalItem) : rawValue

  modelValue.value = finalValue
  emit(&quot;change&quot;, finalValue)
}
</code></pre>

<p>这个逻辑保证 <code>v-model</code> 绑定的数字值不会意外变成字符串。</p>

<h3 id="2-4-属性透传拆分">2.4 属性透传拆分</h3>

<p>这是可搜索模式的关键细节——<strong>哪些属性透传到原生 input，哪些保留在外层 wrapper</strong>：</p>

<pre><code class="language-typescript">const attrs = useAttrs()

/** 透传到原生表单元素的 form/aria 属性 */
const formAttrs = computed(() =&gt; {
  const result: Record&lt;string, unknown&gt; = {}
  for (const [key, value] of Object.entries(attrs)) {
    if (
      key.startsWith(&quot;aria-&quot;) ||
      [&quot;name&quot;, &quot;id&quot;, &quot;role&quot;, &quot;tabindex&quot;].includes(key)
    ) {
      result[key] = value
    }
  }
  return result
})

/** 保留在外层 wrapper 的其余属性（class/style/事件等） */
const wrapperAttrs = computed(() =&gt; {
  const result: Record&lt;string, unknown&gt; = {}
  for (const [key, value] of Object.entries(attrs)) {
    if (!(key in formAttrs.value)) {
      result[key] = value
    }
  }
  return result
})
</code></pre>

<p>为什么必须拆分？如果 <code>aria-label</code> 留在外层 wrapper 而未透传到实际 <code>&lt;input&gt;</code>，屏幕阅读器会无法识别输入框的可访问名称——在 <code>a11y.test.ts</code> 的 axe-core 检查中会报 <code>aria-input-field-name</code> 违规。</p>

<h3 id="2-5-aria-键盘导航-wai-aria-combobox-模式">2.5 ARIA 键盘导航（WAI-ARIA Combobox 模式）</h3>

<p>可搜索模式的键盘导航遵循 WAI-ARIA Combobox 模式：</p>

<pre><code class="language-vue">&lt;!-- 下拉面板 --&gt;
&lt;div
  v-if=&quot;isOpen&quot;
  ref=&quot;dropdownRef&quot;
  class=&quot;mg-select-dropdown&quot;
  role=&quot;listbox&quot;
  :aria-label=&quot;listboxAriaLabel&quot;
  :aria-activedescendant=&quot;focusedIndex &gt;= 0 ? getOptionId(focusedIndex) : undefined&quot;
&gt;
  &lt;!-- 选项 --&gt;
  &lt;div
    v-for=&quot;(item, index) in filteredOptions&quot;
    :id=&quot;getOptionId(index)&quot;
    role=&quot;option&quot;
    :aria-selected=&quot;isSelected(item)&quot;
    :class=&quot;{ 'mg-select-option-focused': focusedIndex === index }&quot;
    @click=&quot;selectOption(item)&quot;
    @mouseenter=&quot;focusedIndex = index&quot;
  &gt;
</code></pre>

<p>支持的操作：</p>

<ul>
<li><code>ArrowDown</code> / <code>ArrowUp</code>：移动高亮（<code>focusedIndex</code>），并 <code>scrollIntoView</code> 保持可视</li>
<li><code>Enter</code>：选中当前高亮选项</li>
<li><code>Esc</code>：关闭下拉</li>
<li>每个选项有唯一 <code>id</code>（基于 <code>useId()</code>），供 <code>aria-activedescendant</code> 引用</li>
</ul>

<h3 id="2-6-多选模式">2.6 多选模式</h3>

<p>多选（<code>multiple + filterable</code>）将 <code>modelValue</code> 变为数组，选中逻辑的核心分支：</p>

<pre><code class="language-typescript">// 多选：切换选中（已选则移除，未选则追加）
if (props.multiple) {
  const current = multipleValues.value
  const isAlreadySelected = current.some((v) =&gt; String(v) === String(value))
  const next = isAlreadySelected
    ? current.filter((v) =&gt; String(v) !== String(value))
    : [...current, value]

  modelValue.value = next
  // 多选保持下拉打开，方便连续多选
  nextTick(() =&gt; inputRef.value?.focus())
  return
}

// 单选：选择后关闭
modelValue.value = value
closeDropdown()
</code></pre>

<p>多选时：</p>

<ul>
<li>已选项渲染为标签（tag），每个标签有 <code>aria-label=&quot;移除 {label}&quot;</code> 的删除按钮</li>
<li>选择后<strong>下拉保持打开</strong>（方便连续多选）</li>
<li>输入框只显示搜索文本，已选标签在外部</li>
</ul>

<h2 id="三-pagination-分页组件-状态同步器">三、Pagination 分页组件：状态同步器</h2>

<h3 id="3-1-api-设计-v1-5-0-实际">3.1 API 设计（v1.5.0 实际）</h3>

<pre><code class="language-typescript">interface Props {
  totalPages: number // 总页数（必传）
  modelValue: number // 当前页码（v-model）
  size?: &quot;sm&quot; | &quot;md&quot; | &quot;lg&quot;
  showQuickJump?: boolean // 首尾页快速跳转按钮（默认 true）
  prevText?: string // 上一页文案（走全局 i18n）
  nextText?: string
  firstText?: string
  lastText?: string
}
</code></pre>

<p>Pagination 使用 <code>defineModel&lt;number&gt;</code> 绑定当前页：</p>

<pre><code class="language-typescript">const currentPage = defineModel&lt;number&gt;({ required: true })
</code></pre>

<h3 id="3-2-快速跳转-页码编辑">3.2 快速跳转 + 页码编辑</h3>

<pre><code class="language-vue">&lt;!-- 显示模式：可点击的数字 → 进入编辑 --&gt;
&lt;span v-else class=&quot;mg-pagination-current&quot; @click=&quot;startEdit&quot;&gt;
  {{ currentPage }}
&lt;/span&gt;

&lt;!-- 编辑模式：输入框 --&gt;
&lt;input
  v-if=&quot;isEditing&quot;
  v-model=&quot;inputPage&quot;
  type=&quot;number&quot;
  :min=&quot;1&quot;
  :max=&quot;totalPages&quot;
  @blur=&quot;commitJump&quot;
  @keyup.enter=&quot;commitJump&quot;
/&gt;
</code></pre>

<p><code>commitJump</code> 的极端边界防御：</p>

<pre><code class="language-typescript">const commitJump = () =&gt; {
  isEditing.value = false
  const newPage = parseInt(String(inputPage.value), 10)

  // 非法输入：放弃并恢复
  if (isNaN(newPage)) {
    inputPage.value = currentPage.value
    return
  }

  goToPage(newPage) // clamp 到 [1, totalPages]
}

const goToPage = (page: number) =&gt; {
  let newPage = page
  if (newPage &lt; 1) newPage = 1
  if (newPage &gt; props.totalPages) newPage = props.totalPages
  if (newPage === currentPage.value) return
  currentPage.value = newPage
  emit(&quot;change&quot;, newPage)
}
</code></pre>

<h3 id="3-3-全局文案-i18n">3.3 全局文案（i18n）</h3>

<p>v1.5.0 加入了全局文案系统。Pagination 的所有 aria-label 和按钮文字都走配置链：</p>

<pre><code class="language-typescript">const texts = useTexts() // 响应式全局文案

const prevTextValue = computed(
  () =&gt; props.prevText ?? texts.value.paginationPrev,
)
const pageInfoLabel = computed(() =&gt;
  formatTemplate(texts.value.paginationPageInfo, {
    current: currentPage.value,
    total: props.totalPages,
  }),
)
</code></pre>

<p>优先级：<strong>组件 prop &gt; setConfig texts &gt; 语言内置文案</strong>。文案支持 <code>{current}</code>/<code>{total}</code> 模板占位符。</p>

<h2 id="四-组合式函数抽离">四、组合式函数抽离</h2>

<p>复杂组件往往需要抽离共享逻辑。v1.5.0 的核心 composables：</p>

<h3 id="4-1-useformfield-input-textarea-共享">4.1 useFormField（Input/Textarea 共享）</h3>

<p>处理 <code>v-model</code> 更新 + 原生事件透传：</p>

<pre><code class="language-typescript">export function useFormField(modelValue, emit) {
  const handleInput = (event: Event) =&gt; {
    modelValue.value = (event.target as HTMLInputElement).value
    emit(&quot;input&quot;, event)
  }
  // change/focus/blur 原生事件透传
  return { handleInput, handleChange, handleBlur, handleFocus }
}
</code></pre>

<h3 id="4-2-usefloating-popover-tooltip-共享">4.2 useFloating（Popover/Tooltip 共享）</h3>

<p>自研悬浮层定位引擎：视口翻转 + 边界修正 + ResizeObserver：</p>

<pre><code class="language-typescript">export function useFloating(options: UseFloatingOptions) {
  // 延迟显示/隐藏
  // 位置计算（按方向定位 + 视口翻转）
  // 滚动/窗口尺寸变化时重新定位
  // ResizeObserver 仅监听悬浮层自身尺寸
  // SSR 安全（isBrowser 守卫）
  return { triggerRef, floatingRef, visible, currentPlacement, floatStyle, show, hide, ... }
}
</code></pre>

<h3 id="4-3-useoverlaybehavior-modal-drawer-共享-位于-usescrolllock-ts">4.3 useOverlayBehavior（Modal/Drawer 共享，位于 useScrollLock.ts）</h3>

<p>滚动锁定 + ESC 关闭 + 焦点陷阱：</p>

<pre><code class="language-typescript">// composables/useScrollLock.ts
export function useOverlayBehavior(isOpen, overlayRef, onClose, options) {
  // body 滚动锁定（模块级 lockCount 计数器，多实例安全）
  // ESC 键关闭
  // Tab 焦点陷阱
}
</code></pre>

<p>该函数与滚动锁定逻辑（<code>lockBodyScroll</code>/<code>unlockBodyScroll</code>）一同定义在 <strong><code>useScrollLock.ts</code></strong> 中——滚动锁、ESC 关闭、焦点陷阱三者在语义上同属&rdquo;浮层行为&rdquo;这一关注点，因此放在同一个文件内。</p>

<p><strong>模块级锁计数</strong>解决多 Modal/Drawer 同时打开的滚动锁冲突——只有最后一个关闭时才恢复 body 滚动。</p>

<h2 id="五-ssr-适配-useid-与-isbrowser">五、SSR 适配：useId 与 isBrowser</h2>

<p>v1.5.0 的 SSR 适配比初版更完善，核心是两条：</p>

<h3 id="5-1-useid-保证-hydration-安全">5.1 useId() 保证 hydration 安全</h3>

<p>所有需要 <code>id</code> 的组件（Modal/Drawer/Select/Tabs）使用 Vue 3 的 <code>useId()</code>：</p>

<pre><code class="language-typescript">const selectBaseId = useId()
const getOptionId = (index: number): string =&gt; `${selectBaseId}-option-${index}`
</code></pre>

<p><code>useId()</code> 在服务端与客户端生成一致的 ID，避免 hydration mismatch。</p>

<h3 id="5-2-isbrowser-守卫">5.2 isBrowser 守卫</h3>

<p>所有 DOM 操作添加浏览器环境守卫：</p>

<pre><code class="language-typescript">const isBrowser =
  typeof window !== &quot;undefined&quot; &amp;&amp; typeof document !== &quot;undefined&quot;

// 在 watch/onMounted/顶层代码中：
if (!isBrowser) return
</code></pre>

<p>配合 <code>useScrollLock.ts</code> 的模块级计数器，<code>lockBodyScroll()</code> 在非浏览器环境下直接跳过 DOM 操作。</p>

<h3 id="5-3-createoverlay-命令式组件-ssr-安全">5.3 createOverlay：命令式组件 SSR 安全</h3>

<pre><code class="language-typescript">// composables/createOverlay.ts
export function createOverlay(component, props, containerClass) {
  if (!isBrowser) return null // SSR 返回 null
  // ...
}
</code></pre>

<h2 id="六-数据与状态流向">六、数据与状态流向</h2>

<pre><code class="language-text">外部数据 ──► 数据适配器 ──► 内部状态
(options)    (getLabel/     (selected/
              getValue)      searchText)
   ▲                            │
   │                            ▼
全局环境 ◄── 状态协调器 ◄── 用户交互
(i18n/SSR)   (watch/event)   (click/keyboard)
</code></pre>

<ul>
<li><strong>左列</strong>：外部输入（数据 + 用户操作）</li>
<li><strong>中间</strong>：适配与协调（把外部世界翻译成内部状态）</li>
<li><strong>右列</strong>：内部状态（组件自管理）</li>
<li><strong>底部</strong>：全局环境（i18n / SSR）反向约束组件行为</li>
</ul>

<h2 id="七-测试策略">七、测试策略</h2>

<p>v1.5.0 的 Select 有 <strong>40+ 测试</strong>，Pagination 有 <strong>14 测试</strong>，覆盖：</p>

<table>
<thead>
<tr>
<th>关注点</th>
<th>测试内容</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>类型回溯</strong></td>
<td>原生模式数字数组 <code>[10,20,30]</code> 选中后 modelValue 仍为 number</td>
</tr>

<tr>
<td><strong>ARIA 导航</strong></td>
<td><code>aria-activedescendant</code> 指向高亮选项、每个 option 有唯一 id</td>
</tr>

<tr>
<td><strong>键盘操作</strong></td>
<td>ArrowDown/Up 高亮、Enter 选中、Esc 关闭、边界不越界</td>
</tr>

<tr>
<td><strong>多选</strong></td>
<td>标签渲染、切换选中、tag 删除、Enter 连续多选</td>
</tr>

<tr>
<td><strong>边界值</strong></td>
<td>搜索空结果、外部 modelValue 变化、blur 时下拉保持打开</td>
</tr>

<tr>
<td><strong>无障碍</strong></td>
<td>axe-core 对 Select（原生+可搜索）无违规</td>
</tr>
</tbody>
</table>
<p>以及 <strong>SSR 检查</strong>：<code>renderToString</code> 确认组件在服务端不崩溃且浮层默认隐藏。</p>

<h2 id="八-总结">八、总结</h2>

<table>
<thead>
<tr>
<th>关注点</th>
<th>简单组件（Button）</th>
<th>复杂组件（Select / Pagination）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Props 数量</strong></td>
<td>较少（11）</td>
<td>较多（10-15）</td>
</tr>

<tr>
<td><strong>数据格式</strong></td>
<td>固定（字符串）</td>
<td>灵活（支持多种数组，可配置字段，类型防腐）</td>
</tr>

<tr>
<td><strong>状态管理</strong></td>
<td>无内部状态</td>
<td>可搜索文本、多选数组、编辑状态、下拉显隐</td>
</tr>

<tr>
<td><strong>无障碍</strong></td>
<td>原生语义</td>
<td>WAI-ARIA Combobox 模式（listbox/option/activedescendant）</td>
</tr>

<tr>
<td><strong>逻辑复用</strong></td>
<td>不需要</td>
<td>组合式函数（useFormField/useFloating/useOverlayBehavior）</td>
</tr>

<tr>
<td><strong>SSR 适配</strong></td>
<td>自动</td>
<td>useId hydration 安全 + isBrowser 守卫</td>
</tr>

<tr>
<td><strong>i18n</strong></td>
<td>少数文案</td>
<td>全局配置链（prop &gt; setConfig &gt; 内置）</td>
</tr>

<tr>
<td><strong>测试策略</strong></td>
<td>快照、事件触发</td>
<td>状态组合、边界值、键盘模拟、类型回溯、axe-core</td>
</tr>
</tbody>
</table>
<p>一个优秀的复杂组件，对内要像吸尘器一样容纳各种奇葩的后端数据格式（通过 Key 映射和类型回溯），对外要像绅士一样克制地与全局环境（i18n、SSR、键盘）发生耦合。<strong>高内聚、低耦合</strong>，在这两类组件身上体现得淋漓尽致。</p>

<hr>

<h2 id="关于-moongate-vue">🌙 关于 Moongate Vue</h2>

<p>本文来自 Moongate Vue 组件库设计实战系列（共 4 篇），所有内容均基于真实项目实践：</p>

<ul>
<li><strong>项目仓库</strong>：<a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">github.com/yuelinghuashu/moongate-vue</a> — 极简 Vue 3 组件库，零依赖、CSS 优先、25KB gzip</li>
<li><strong>真实案例</strong>：<a href="https://moongate.top" target="_blank">moongate.top</a> — 个人博客，从 Nuxt UI v4 迁移至 Moongate Vue 构建</li>
<li><strong>在线文档</strong>：<a href="https://vue.moongate.top" target="_blank">vue.moongate.top</a> — 组件 API 与主题定制指南</li>
</ul>
]]></content:encoded>
      <description><![CDATA[以 Select 和 Pagination 为例，深入探讨 Vue 3 复杂组件的 API 设计、数据格式适配、类型回溯、可搜索/多选、ARIA 键盘导航、组合式函数抽离及 SSR 适配，揭示工业级组件背后的设计权衡与实现细节。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Vue 3 简单组件开发实战：从 Button 组件看 API 设计]]></title>
      <link>https://moongate.top/docs/vue-component-api-design</link>
      <guid isPermaLink="true">https://moongate.top/docs/vue-component-api-design</guid>
      <pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>极简不是简陋，克制不是缺失——以 Button 为例，看一个 11 个 props 的组件如何覆盖日常 90% 的按钮场景。</p>
</blockquote>

<h2 id="一-背景与参考">一、背景与参考</h2>

<p>在之前的文章中，我们讨论了设计令牌优先于原子化 CSS 的理念，以及 CSS 优先 + 组件薄封装的架构。但有一个问题始终没有深入：<strong>具体到单个组件，API 到底该怎么设计？</strong></p>

<p>设计初期，我深度参考了 Nuxt UI v4 的设计思路。Nuxt UI v4 将对复杂样式和交互的封装收束为 <code>variant</code>/<code>color</code>/<code>size</code> 几个核心维度，这正是我想要的——把复杂逻辑<strong>内聚于组件内部</strong>，对外只暴露最精简的 API。</p>

<p>下文以 Button 组件为例，一步步展示我的设计取舍和思考过程。<strong>本文基于 v1.5.0 的实际实现</strong>——经过多个版本迭代，Button 的 API 已经比初版更完善。</p>

<h2 id="二-从需求出发-button-需要什么">二、从需求出发：Button 需要什么？</h2>

<p>一个按钮组件最基本的功能：</p>

<ul>
<li>显示文字</li>
<li>点击触发事件</li>
<li>禁用状态</li>
<li>不同样式（主要、次要、危险等）</li>
</ul>

<p>但只有这些够吗？我们看看实际使用场景：</p>

<pre><code class="language-vue">&lt;!-- 带图标的按钮 --&gt;
&lt;Button&gt;
  &lt;template #icon&gt;🔍&lt;/template&gt;
  搜索
&lt;/Button&gt;

&lt;!-- 加载状态 --&gt;
&lt;Button loading&gt;提交中&lt;/Button&gt;

&lt;!-- 块级按钮（占满宽度） --&gt;
&lt;Button block&gt;全宽按钮&lt;/Button&gt;

&lt;!-- 不同尺寸 --&gt;
&lt;Button size=&quot;sm&quot;&gt;小号&lt;/Button&gt;
</code></pre>

<p>经过分析，Button 组件在 v1.5.0 支持的需求：</p>

<table>
<thead>
<tr>
<th>需求</th>
<th>实现方式</th>
</tr>
</thead>

<tbody>
<tr>
<td>文字内容</td>
<td>默认插槽 或 <code>label</code> prop</td>
</tr>

<tr>
<td>点击事件</td>
<td><code>click</code> 事件</td>
</tr>

<tr>
<td>禁用状态</td>
<td><code>disabled</code> prop</td>
</tr>

<tr>
<td>加载状态</td>
<td><code>loading</code> prop</td>
</tr>

<tr>
<td>加载时保留文字</td>
<td><code>showLabelWhileLoading</code> prop</td>
</tr>

<tr>
<td>加载时自定义文字</td>
<td><code>loadingLabel</code> prop + <code>#loading-label</code> 插槽</td>
</tr>

<tr>
<td>不同样式</td>
<td><code>variant</code> + <code>color</code></td>
</tr>

<tr>
<td>不同尺寸</td>
<td><code>size</code> prop</td>
</tr>

<tr>
<td>块级宽度</td>
<td><code>block</code> prop</td>
</tr>

<tr>
<td>图标</td>
<td><code>icon</code> prop 或 <code>#icon</code> 插槽</td>
</tr>

<tr>
<td>原生按钮类型</td>
<td><code>type</code> prop（button/submit/reset）</td>
</tr>
</tbody>
</table>

<h2 id="三-props-设计-类型-默认值-优先级">三、Props 设计：类型、默认值、优先级</h2>

<h3 id="3-1-基础-props">3.1 基础 Props</h3>

<pre><code class="language-typescript">interface Props {
  label?: string // 按钮文字
  disabled?: boolean // 是否禁用
  loading?: boolean // 是否加载中
  block?: boolean // 是否为块级
  type?: &quot;button&quot; | &quot;submit&quot; | &quot;reset&quot; // 原生按钮类型
  showLabelWhileLoading?: boolean // 加载时是否保留文字
  loadingLabel?: string // 加载时的自定义文字
}

const props = withDefaults(defineProps&lt;Props&gt;(), {
  label: &quot;&quot;,
  disabled: false,
  loading: false,
  block: false,
  type: &quot;button&quot;, // 默认 button，防止表单意外提交
  showLabelWhileLoading: false,
})
</code></pre>

<h4 id="为什么默认-type-button">为什么默认 <code>type=&quot;button&quot;</code></h4>

<p>这是从实际项目踩坑中学到的重要决策。如果使用原生 <code>&lt;button&gt;</code> 的默认类型 <code>submit</code>，当按钮放在 <code>&lt;form&gt;</code> 里时，点击会意外提交表单。将其显式默认为 <code>button</code> 可避免绝大多数不期望的表单行为。</p>

<h3 id="3-2-变体系统-variant-color">3.2 变体系统：variant + color</h3>

<p>常见的按钮类型有：主要按钮、次要按钮、边框按钮、幽灵按钮。受 Nuxt UI v4 的 <code>variant</code> + <code>color</code> 设计启发，我选择将&rdquo;视觉模式&rdquo;与&rdquo;语义颜色&rdquo;完全解耦——<code>variant</code> 只控制 <code>filled</code> / <code>outline</code> 两种视觉模式，<code>color</code> 只控制 <code>primary</code> / <code>success</code> / <code>warning</code> / <code>error</code> 四种语义颜色。</p>

<p>为什么只保留 <code>filled</code> 和 <code>outline</code>，删除了 <code>ghost</code>？</p>

<table>
<thead>
<tr>
<th>变体</th>
<th>使用频率</th>
<th>是否保留</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>filled</code></td>
<td>🔥🔥🔥🔥🔥 极高</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>outline</code></td>
<td>🔥🔥🔥🔥 高</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>ghost</code></td>
<td>🔥 低</td>
<td>❌ 删除（可用 <code>outline</code> 替代）</td>
</tr>
</tbody>
</table>
<p>同样，颜色只保留 4 种：</p>

<table>
<thead>
<tr>
<th>颜色</th>
<th>使用频率</th>
<th>是否保留</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>primary</code></td>
<td>🔥🔥🔥🔥🔥 极高</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>success</code></td>
<td>🔥🔥🔥 中</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>warning</code></td>
<td>🔥 低</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>error</code></td>
<td>🔥🔥 中</td>
<td>✅ 保留</td>
</tr>

<tr>
<td><code>neutral</code></td>
<td>🔥 低</td>
<td>❌ 删除（可用 <code>outline</code> 替代）</td>
</tr>
</tbody>
</table>

<h3 id="3-3-尺寸设计">3.3 尺寸设计</h3>

<p>尺寸只保留 <code>sm</code> / <code>md</code> / <code>lg</code> 三档。我删除了 <code>xs</code> 和 <code>xl</code>：极小尺寸可以用 Badge 或其他非按钮组件替代，而个人博客里几乎碰不到超大尺寸的场景。</p>

<p>默认尺寸设为 <code>sm</code>——常见的按钮默认高度约 32-34px，正好对应我们的 <code>sm</code>。</p>

<h3 id="3-4-图标设计-prop-与插槽共存">3.4 图标设计：prop 与插槽共存</h3>

<p>初版设计时，我只提供 <code>#icon</code> 插槽，不提供 <code>icon</code> prop——理由是&rdquo;保持单一职责&rdquo;。但实际使用中发现两个问题：</p>

<ol>
<li><strong>简单图标（如 <code>✓</code>、emoji）用插槽太啰嗦</strong>：<code>&lt;template #icon&gt;✓&lt;/template&gt;</code> 比 <code>icon=&quot;✓&quot;</code> 多了 20 个字符</li>
<li><strong>图标库组件（如 lucide-vue-next 的 IconHome）用插槽不够直观</strong>：需要用 <code>&lt;component :is=&quot;IconHome&quot; /&gt;</code> 包一层</li>
</ol>

<p>经过多版本迭代，最终同时支持 <code>icon?: string | Component</code>（字符串或 Vue 组件）与 <code>#icon</code> 插槽，并建立明确的优先级：</p>

<pre><code class="language-vue">&lt;!-- 使用插槽（优先级更高，更灵活） --&gt;
&lt;Button&gt;
  &lt;template #icon&gt;🔍&lt;/template&gt;
  搜索
&lt;/Button&gt;

&lt;!-- 使用字符串 prop --&gt;
&lt;Button icon=&quot;✓&quot; label=&quot;确认&quot; /&gt;

&lt;!-- 使用 Vue 组件 prop --&gt;
&lt;Button :icon=&quot;IconHome&quot; label=&quot;首页&quot; /&gt;
</code></pre>

<p>模板中的实现逻辑：</p>

<pre><code class="language-vue">&lt;span v-if=&quot;hasIconSlot || icon&quot; class=&quot;mg-button-icon&quot;&gt;
  &lt;!-- 插槽优先于 prop --&gt;
  &lt;slot name=&quot;icon&quot;&gt;
    &lt;!-- prop 是 Vue 组件时渲染组件 --&gt;
    &lt;component :is=&quot;icon&quot; v-if=&quot;typeof icon !== 'string'&quot; /&gt;
    &lt;!-- prop 是字符串时直接渲染文本 --&gt;
    &lt;span v-else-if=&quot;icon&quot;&gt;{{ icon }}&lt;/span&gt;
  &lt;/slot&gt;
&lt;/span&gt;
</code></pre>

<p><strong>设计原则：插槽优先于 prop</strong>。<code>hasIconSlot</code> 检测是否传入 <code>#icon</code> 插槽，如果有则完全忽略 <code>icon</code> prop。这保证了灵活性——当用户需要自定义图标布局时，插槽总是能覆盖 prop 的默认行为。</p>

<blockquote>
<p>设计考量：我合并为单个 <code>icon</code> prop（左侧图标——这是个人博客场景 90% 的需求），保留 <code>#icon</code> 插槽用于完全控制。</p>
</blockquote>

<h2 id="四-插槽设计-默认插槽-vs-label-prop">四、插槽设计：默认插槽 vs label prop</h2>

<p>为了支持快速写法和自定义内容，同时提供 <code>label</code> prop 和默认插槽，模板中通过 <code>&lt;slot&gt;{{ label }}&lt;/slot&gt;</code> 实现——有插槽内容时用插槽，否则回退到 <code>label</code>。</p>

<p><code>hasLabel</code> 的判断逻辑有一个容易忽视的细节：</p>

<pre><code class="language-typescript">const hasLabel = computed(() =&gt; props.label !== &quot;&quot; || !!slots.default)
</code></pre>

<p>注意这里是 <code>props.label !== &quot;&quot;</code> 而不是 <code>!!props.label</code>。为什么？</p>

<ul>
<li><code>withDefaults</code> 会将 <code>undefined</code> 解析为默认值 <code>&quot;&quot;</code></li>
<li>当外部显式传入 <code>label=&quot;&quot;</code> 时，<strong>我们不渲染空 label 容器</strong>——这是「纯图标按钮」的经典场景，空容器会导致图标无法垂直居中</li>
<li>但如果有默认插槽（无论插槽内容是否为空），仍然渲染容器</li>
</ul>

<pre><code class="language-vue">&lt;!-- 纯图标按钮：label 为空，不渲染空 label 容器 --&gt;
&lt;Button icon=&quot;🔍&quot; /&gt;

&lt;!-- 带插槽内容：即使 label 为空也渲染 --&gt;
&lt;Button&gt;&lt;template #icon&gt;🔍&lt;/template&gt;搜索&lt;/Button&gt;
</code></pre>

<p>这解决了<strong>纯图标按钮居中问题</strong>——空的 <code>.mg-button-label</code> 会占据空间导致图标不居中。</p>

<h2 id="五-状态处理">五、状态处理</h2>

<h3 id="5-1-禁用状态与加载状态">5.1 禁用状态与加载状态</h3>

<p><code>disabled</code> 和 <code>loading</code> 都会禁用按钮，但<strong>只有 <code>disabled</code> 时 <code>click</code> 事件才完全阻止</strong>（原生 disabled 属性）；<code>loading</code> 状态我们还希望保留正确的语义——用户知道按钮在&rdquo;处理中&rdquo;。</p>

<pre><code class="language-typescript">const handleClick = (event: MouseEvent) =&gt; {
  if (props.disabled || props.loading) return
  emit(&quot;click&quot;, event)
}
</code></pre>

<p>模板中通过 <code>:disabled=&quot;disabled || loading&quot;</code> 让两种状态都禁用按钮，但 click 事件仍由 <code>handleClick</code> 统一拦截——这保证了程序化调用（<code>.trigger('click')</code>）时也遵守禁用语义。</p>

<h3 id="5-2-加载状态增强">5.2 加载状态增强</h3>

<p>初版的 <code>loading</code> 只显示旋转动画，隐藏图标和文字。v1.5.0 增加了两个增强：</p>

<ul>
<li><code>showLabelWhileLoading</code>：加载时是否保留文字</li>
<li><code>loadingLabel</code>：加载时的自定义文字（默认复用 <code>label</code>）</li>
<li><code>#loading-label</code> 插槽：完全自定义加载文字（优先级最高）</li>
</ul>

<pre><code class="language-vue">&lt;template v-if=&quot;loading&quot;&gt;
  &lt;span class=&quot;mg-button-loading-icon&quot; /&gt;
  &lt;!-- 根据开关决定是否显示 label --&gt;
  &lt;span v-if=&quot;showLabelWhileLoading&quot; class=&quot;mg-button-label&quot;&gt;
    &lt;!-- 插槽 &gt; loadingLabel prop &gt; label --&gt;
    &lt;slot name=&quot;loading-label&quot;&gt;{{ loadingLabel || label }}&lt;/slot&gt;
  &lt;/span&gt;
&lt;/template&gt;
</code></pre>

<p>使用场景（三种递进的控制粒度）：</p>

<pre><code class="language-vue">&lt;!-- ① 默认：只显示加载动画 --&gt;
&lt;Button loading label=&quot;保存&quot; /&gt;

&lt;!-- ② 保留文字：提示用户&quot;正在保存&quot; --&gt;
&lt;Button loading :show-label-while-loading=&quot;true&quot; label=&quot;保存&quot; /&gt;

&lt;!-- ③ 完全自定义：插槽覆盖一切 --&gt;
&lt;Button loading :show-label-while-loading=&quot;true&quot;&gt;
  &lt;template #loading-label&gt;⏳ 拼命保存中...&lt;/template&gt;
&lt;/Button&gt;
</code></pre>

<p>纯 CSS 实现加载动画：</p>

<pre><code class="language-css">.mg-button-loading-icon {
  width: 1rem;
  height: 1rem;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: mg-button-spin 0.6s linear infinite;
}
@keyframes mg-button-spin {
  to {
    transform: rotate(360deg);
  }
}
</code></pre>

<h2 id="六-css-样式设计">六、CSS 样式设计</h2>

<h3 id="6-1-基础样式">6.1 基础样式</h3>

<p>按钮使用 <code>inline-flex</code> 布局，内容水平和垂直居中，直角边框（<code>--ui-radius-none</code>），内边距和字体大小使用设计令牌。</p>

<pre><code class="language-css">.mg-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ui-spacing-sm);
  font-weight: 500;
  transition: all var(--ui-motion-duration-neural) ease;
  cursor: pointer;
  border-radius: var(--ui-radius-none);
  border: none;
  background: transparent;
  white-space: nowrap;
  padding: var(--ui-spacing-sm) var(--ui-spacing-md);
  font-size: var(--ui-typography-size-body);
}
</code></pre>

<h3 id="6-2-尺寸变体">6.2 尺寸变体</h3>

<table>
<thead>
<tr>
<th>尺寸</th>
<th>内边距</th>
<th>字体大小</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>sm</code></td>
<td><code>sm</code> / <code>md</code></td>
<td><code>--ui-typography-size-code</code> (13px)</td>
</tr>

<tr>
<td><code>md</code></td>
<td><code>md</code> / <code>lg</code></td>
<td><code>--ui-typography-size-body</code> (15px)</td>
</tr>

<tr>
<td><code>lg</code></td>
<td><code>lg</code> / <code>xl</code></td>
<td><code>1.125rem</code> (18px)</td>
</tr>
</tbody>
</table>

<h3 id="6-3-颜色变体-hover-active-状态">6.3 颜色变体：hover/active 状态</h3>

<p>v1.5.0 的颜色变体不仅定义了基础色，还完善了 hover/active 反馈。使用 <code>color-mix()</code> 在令牌颜色基础上混合黑色实现：</p>

<pre><code class="language-css">.mg-button-filled-primary {
  background-color: var(--ui-primary);
  color: white;
}
.mg-button-filled-primary:hover:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), black 10%);
}
.mg-button-filled-primary:active:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), black 20%);
}

/* outline 变体：透明背景 + 边框 */
.mg-button-outline-primary {
  background-color: transparent;
  color: var(--ui-primary);
  border: 1px solid var(--ui-primary);
}
.mg-button-outline-primary:hover:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), transparent 90%);
}
</code></pre>

<p>使用 <code>:not(:disabled)</code> 确保禁用状态不触发 hover 效果。</p>

<h3 id="6-4-图标与文字容器">6.4 图标与文字容器</h3>

<p>图标容器使用 <code>inline-flex</code> 并设置 <code>line-height: 0</code> 来消除行高影响，内部的 SVG 或 iconify 图标强制块级并设置宽高为 <code>1em</code>。配合 <code>:empty</code> 伪类隐藏空标签，修复只有图标时的居中问题：</p>

<pre><code class="language-css">.mg-button-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  line-height: 0;
}
.mg-button-icon svg,
.mg-button-icon .iconify {
  display: block;
  width: 1em;
  height: 1em;
}

/* 空标签隐藏 - 修复只有图标时的居中问题 */
.mg-button-label:empty {
  display: none;
}
</code></pre>

<p>CSS 层的 <code>:empty</code> 伪类 + 组件层的 <code>hasLabel</code> 判断共同保证了纯图标按钮的正确居中。</p>

<h2 id="七-无障碍设计">七、无障碍设计</h2>

<p>当前 Button 不添加 <code>aria-busy</code> 和 <code>aria-disabled</code> 属性。无障碍策略的核心是依赖原生语义：</p>

<ul>
<li><strong><code>disabled</code> 原生属性</strong>已经可以让屏幕阅读器正确读出禁用状态</li>
<li><strong>加载状态</strong>通过视觉反馈（旋转动画）表达，比额外的 ARIA 属性更直观</li>
<li>移除多余的 ARIA 属性，避免和原生语义重复</li>
</ul>

<p>这不是&rdquo;极简 ≠ 简陋&rdquo;的妥协，而是<strong>避免 ARIA 过度使用</strong>——原生 HTML 语义（<code>&lt;button disabled&gt;</code>）本身就是最可靠的无障碍。项目真正的无障碍保障来自测试层：<code>a11y.test.ts</code> 使用 axe-core 对 12 个核心组件做自动化规范检查（<code>expectNoViolations</code>）。</p>

<h2 id="八-属性透传">八、属性透传</h2>

<p>模板根元素通过 <code>v-bind=&quot;$attrs&quot;</code> 透传原生属性（见 §九 完整代码），用户可以直接传入 <code>id</code>、<code>name</code>、<code>data-*</code>、<code>aria-*</code> 等属性：</p>

<pre><code class="language-vue">&lt;Button id=&quot;submit-btn&quot; name=&quot;submit&quot; data-testid=&quot;submit&quot;&gt;
  提交
&lt;/Button&gt;
</code></pre>

<p>配合 <code>defineOptions({ inheritAttrs: false })</code>，外部 class 会通过 <code>$attrs</code> 暴露给用户自行处理，同时组件内部的 class 绑定不会冲突。</p>

<h2 id="九-最终代码-v1-5-0-实际实现">九、最终代码（v1.5.0 实际实现）</h2>

<pre><code class="language-vue">&lt;template&gt;
  &lt;button
    v-bind=&quot;$attrs&quot;
    :type=&quot;type&quot;
    class=&quot;mg-button&quot;
    :class=&quot;[
      `mg-button-${variant}-${color}`,
      `mg-button-${size}`,
      { 'mg-button-block': block, 'mg-button-loading': loading },
    ]&quot;
    :disabled=&quot;disabled || loading&quot;
    @click=&quot;handleClick&quot;
  &gt;
    &lt;!-- 加载状态 --&gt;
    &lt;template v-if=&quot;loading&quot;&gt;
      &lt;span class=&quot;mg-button-loading-icon&quot; /&gt;
      &lt;span v-if=&quot;showLabelWhileLoading&quot; class=&quot;mg-button-label&quot;&gt;
        &lt;slot name=&quot;loading-label&quot;&gt;{{ loadingLabel || label }}&lt;/slot&gt;
      &lt;/span&gt;
    &lt;/template&gt;

    &lt;!-- 正常状态 --&gt;
    &lt;template v-else&gt;
      &lt;span v-if=&quot;hasIconSlot || icon&quot; class=&quot;mg-button-icon&quot;&gt;
        &lt;slot name=&quot;icon&quot;&gt;
          &lt;component :is=&quot;icon&quot; v-if=&quot;typeof icon !== 'string'&quot; /&gt;
          &lt;span v-else-if=&quot;icon&quot;&gt;{{ icon }}&lt;/span&gt;
        &lt;/slot&gt;
      &lt;/span&gt;
      &lt;span v-if=&quot;hasLabel&quot; class=&quot;mg-button-label&quot;&gt;
        &lt;slot&gt;{{ label }}&lt;/slot&gt;
      &lt;/span&gt;
    &lt;/template&gt;
  &lt;/button&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
import { useSlots, computed } from &quot;vue&quot;
import type { Component } from &quot;vue&quot;
import type { Size, AddonColor } from &quot;../types/components&quot;

defineOptions({ name: &quot;Button&quot;, inheritAttrs: false })

type Variant = &quot;filled&quot; | &quot;outline&quot;
type ButtonType = &quot;button&quot; | &quot;submit&quot; | &quot;reset&quot;

interface Props {
  label?: string
  variant?: Variant
  color?: AddonColor
  size?: Size
  type?: ButtonType
  disabled?: boolean
  loading?: boolean
  showLabelWhileLoading?: boolean
  loadingLabel?: string
  block?: boolean
  icon?: string | Component
}

const props = withDefaults(defineProps&lt;Props&gt;(), {
  label: &quot;&quot;,
  variant: &quot;filled&quot;,
  color: &quot;primary&quot;,
  size: &quot;sm&quot;,
  type: &quot;button&quot;,
  disabled: false,
  loading: false,
  showLabelWhileLoading: false,
  block: false,
})

defineSlots&lt;{
  default: () =&gt; any
  icon: () =&gt; any
  &quot;loading-label&quot;: () =&gt; any
}&gt;()

const slots = useSlots()
const hasIconSlot = computed(() =&gt; !!slots.icon)
const hasLabel = computed(() =&gt; props.label !== &quot;&quot; || !!slots.default)

const emit = defineEmits&lt;{ click: [event: MouseEvent] }&gt;()

const handleClick = (event: MouseEvent) =&gt; {
  if (props.disabled || props.loading) return
  emit(&quot;click&quot;, event)
}
&lt;/script&gt;
</code></pre>

<h2 id="十-设计取舍总结">十、设计取舍总结</h2>

<p>回顾上面的设计过程，几个关键取舍：</p>

<ul>
<li><strong><code>variant</code> 与 <code>color</code> 的解耦</strong>：关注&rdquo;视觉模式&rdquo;与&rdquo;语义颜色&rdquo;分离</li>
<li><strong>尺寸的精简</strong>：3 种尺寸足够覆盖个人博客场景</li>
<li><strong>加载状态的精细化</strong>：<code>showLabelWhileLoading</code>/<code>loadingLabel</code> 是对&rdquo;加载时文案&rdquo;需求的响应</li>
<li><strong>图标支持的灵活性</strong>：prop 快速写法 + 插槽完全控制</li>
</ul>

<h2 id="十一-关于加载状态宽度变化的讨论">十一、关于加载状态宽度变化的讨论</h2>

<p>组件开发中有一个经典陷阱——<strong>按钮加载时宽度变化导致布局偏移（CLS）</strong>。</p>

<p>常见的解法是 <code>min-width</code> 预留 + 加载图标绝对定位。但在 v1.5.0 的实际实现中，我<strong>没有采用这种方式</strong>，原因是：</p>

<ol>
<li><strong><code>min-width: 88px</code> 是硬编码值</strong>，会破坏&rdquo;小型组件响应不同内容宽度&rdquo;的灵活性</li>
<li>绝对定位的加载图标在 <code>loading=&quot;true&quot;</code> 时脱离文档流，如果按钮内有其他内容（如加载文字），布局仍可能变化</li>
<li>更好的性能优化是<strong>保证加载状态的文案长度接近正常状态</strong>（<code>loadingLabel</code> 帮助用户保持文案一致）</li>
</ol>

<p>如果你的场景确实对 CLS 要求严格（如电商下单按钮），可以在业务层手动添加：</p>

<pre><code class="language-css">.my-order-button {
  min-width: 120px; /* 根据你实际按钮宽度预留 */
}
</code></pre>

<p>组件库层面保持灵活，用户按需优化。</p>

<h2 id="十二-设计决策总结">十二、设计决策总结</h2>

<table>
<thead>
<tr>
<th>决策</th>
<th>原因</th>
</tr>
</thead>

<tbody>
<tr>
<td>删除 <code>xs</code> 尺寸</td>
<td>使用频率低，简化 API</td>
</tr>

<tr>
<td>删除 <code>ghost</code> 变体</td>
<td>可用 <code>outline</code> 替代</td>
</tr>

<tr>
<td>删除 <code>neutral</code> 颜色</td>
<td>可用 <code>outline</code> + 默认色替代</td>
</tr>

<tr>
<td>默认尺寸为 <code>sm</code></td>
<td>主流 UI 库默认按钮约 32px</td>
</tr>

<tr>
<td>默认 <code>type=&quot;button&quot;</code></td>
<td>防止表单意外提交</td>
</tr>

<tr>
<td><code>icon</code> prop + <code>#icon</code> 插槽共存</td>
<td>快速写法 + 完全控制，插槽优先</td>
</tr>

<tr>
<td><code>label</code> prop + 默认插槽</td>
<td>两种写法都支持</td>
</tr>

<tr>
<td><code>hasLabel = label !== ''</code></td>
<td>纯图标按钮不渲染空 label 容器</td>
</tr>

<tr>
<td>loading 增强</td>
<td><code>showLabelWhileLoading</code> + <code>loadingLabel</code></td>
</tr>

<tr>
<td><code>v-bind=&quot;$attrs&quot;</code></td>
<td>透传原生属性，保持灵活性</td>
</tr>

<tr>
<td>不添加 aria-busy/aria-disabled</td>
<td>依赖原生 disabled 语义 + axe-core 测试保障</td>
</tr>

<tr>
<td>不做 CLS 魔法</td>
<td>保持灵活性，用户按需优化</td>
</tr>
</tbody>
</table>

<h2 id="十三-测试保障">十三、测试保障</h2>

<p>v1.5.0 的 Button 有 <strong>20 个组件测试</strong>，覆盖：</p>

<ul>
<li>默认 props 渲染</li>
<li>variant/color/size 变体 class</li>
<li>loading 状态（自动禁用、隐藏文字、showLabelWhileLoading、loadingLabel、插槽）</li>
<li>纯图标按钮（label 为空不渲染容器）</li>
<li><code>icon</code> prop（字符串渲染文本、Component 渲染组件、插槽优先于 prop）</li>
<li>disabled/loading 时 click 不触发</li>
<li>属性透传（id、data-*）</li>
<li><code>type</code> prop 透传</li>
</ul>

<p>以及 <strong>axe-core 可访问性检查</strong>（Button 无违规）和 <strong>SSR 渲染检查</strong>。</p>

<hr>

<p>本篇以 Button 为例梳理了简单组件的设计要点。下一篇将深入复杂组件，涵盖数据适配、内部状态、逻辑复用等更高级的话题。</p>

<hr>

<h2 id="关于-moongate-vue">🌙 关于 Moongate Vue</h2>

<p>本文来自 Moongate Vue 组件库设计实战系列（共 4 篇），所有内容均基于真实项目实践：</p>

<ul>
<li><strong>项目仓库</strong>：<a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">github.com/yuelinghuashu/moongate-vue</a> — 极简 Vue 3 组件库，零依赖、CSS 优先、25KB gzip</li>
<li><strong>真实案例</strong>：<a href="https://moongate.top" target="_blank">moongate.top</a> — 个人博客，从 Nuxt UI v4 迁移至 Moongate Vue 构建</li>
<li><strong>在线文档</strong>：<a href="https://vue.moongate.top" target="_blank">vue.moongate.top</a> — 组件 API 与主题定制指南</li>
</ul>
]]></content:encoded>
      <description><![CDATA[以 Button 这一简单组件为例，深入探讨 Vue 3 组件库的 API 设计哲学，涵盖 Props 定义、变体系统、尺寸取舍、插槽设计、状态管理、无障碍支持及与主流 UI 库的对比，揭示极简 API 背后的设计权衡。]]></description>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[CSS 优先 + 组件薄封装：一个 25KB 组件库的极简实践]]></title>
      <link>https://moongate.top/docs/css-first-component-library</link>
      <guid isPermaLink="true">https://moongate.top/docs/css-first-component-library</guid>
      <pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>四层 CSS 架构把&rdquo;样式&rdquo;从&rdquo;组件&rdquo;中彻底解耦：设计令牌是 API，组件只做组合。这篇讲架构怎么落地，以及 25KB 体积是怎么守住的。</p>
</blockquote>

<h2 id="回顾-第一篇文章的结论">回顾：第一篇文章的结论</h2>

<p>在上一篇文章<a href="./design-tokens-vs-atomic-css">《design-tokens-vs-atomic-css》</a>中，我分享了尝试用 UnoCSS 映射已有设计令牌的失败经历。核心结论是：</p>

<ul>
<li><strong>设计令牌是地基，原子化只是涂料</strong></li>
<li>强行映射只会增加维护成本，得不偿失</li>
<li>对于已有成熟设计令牌的项目，原子化 CSS 不是必需品</li>
</ul>

<p>那么，<strong>不用原子化 CSS，组件库应该怎么写？</strong></p>

<p>这篇文章给出答案——以及 v1.5.0 在初版方案之上的工程进化。</p>

<h2 id="最终架构-四层-css-架构">最终架构：四层 CSS 架构</h2>

<p>整个样式系统分为多个层级，职责清晰、层层依赖：</p>

<pre><code class="language-text">设计令牌层（自动生成）          ← 组件库的核心 API 层
├─ tokens/colors.css            颜色令牌（浅/深各 68 个变量）
├─ tokens/layout.css            间距 / 字体 / 动效 / z-index 令牌
│
↓ 组件通过 var(--ui-*) 引用
│
组件样式层（手写）
├─ components/                  各组件独立样式文件
│  （Button.css, Card.css, ... 共 20+ 文件）
│
↓ 引用工具类
│
工具层（手写）
├─ utilities/                   极简语义工具类（颜色 / 文本 / 契约变量）
│
↓ 统一入口
│
入口层（手写）
├─ index.css                    导入令牌 + 组件样式 + 工具类
│
reset.css（可选，独立导出，不属于层级链）
</code></pre>

<h3 id="各文件-文件夹职责">各文件/文件夹职责</h3>

<table>
<thead>
<tr>
<th>文件/文件夹</th>
<th>职责</th>
<th>生成方式</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>tokens/colors.css</code></td>
<td>浅色/深色模式颜色令牌（各 68 个变量）</td>
<td>主题脚本自动生成</td>
</tr>

<tr>
<td><code>tokens/layout.css</code></td>
<td>间距、字体、动效、断点、z-index 令牌</td>
<td>主题脚本自动生成</td>
</tr>

<tr>
<td><code>components/</code></td>
<td>各组件独立样式文件（Button.css 等）</td>
<td>手写</td>
</tr>

<tr>
<td><code>utilities/</code></td>
<td>极简工具类</td>
<td>手写</td>
</tr>

<tr>
<td><code>reset.css</code></td>
<td>可选全局重置（box-sizing），独立导出</td>
<td>手写，不属于层级链</td>
</tr>

<tr>
<td><code>index.css</code></td>
<td>总入口，导入令牌 + 组件 + 工具</td>
<td>手写</td>
</tr>
</tbody>
</table>

<h3 id="设计令牌即-api">设计令牌即 API</h3>

<p>在这种模式下，<code>colors.css</code> 不仅仅是样式，它更像是组件库的 <strong>Configuration API</strong>。用户通过修改这些 CSS 变量（如 <code>--ui-primary</code>、<code>--ui-spacing-md</code>），就能在不触碰任何 JS 逻辑的情况下，完成整套 UI 的换肤。这是设计令牌最核心的价值——<strong>样式配置与代码逻辑彻底分离</strong>。</p>

<h3 id="工程红利-多框架复用">工程红利：多框架复用</h3>

<p>这种解耦意味着，如果明天我想把项目从 Vue 迁移到 React 或 Svelte，我只需要重写一遍 ~50 行的逻辑组件，而那套核心样式可以原地复用，无需任何改动。<strong>这是&rdquo;样式绑定逻辑&rdquo;的原子化方案永远无法做到的。</strong></p>

<h2 id="极简组件-button-vue-为例">极简组件：Button.vue 为例</h2>

<p>有了全局 CSS 类，Vue 组件只需要做三件事：</p>

<ol>
<li>组合正确的类名</li>
<li>处理交互逻辑（click、disabled、loading）</li>
<li>透传插槽</li>
</ol>

<p>以 v1.5.0 的实际代码为例，模板核心只有三部分（完整代码见<a href="./vue-component-api-design">第 3 篇 §九</a>）：</p>

<pre><code class="language-vue">&lt;!-- Button.vue 核心：类名组合 + 状态 + 透传 --&gt;
&lt;button
  v-bind=&quot;$attrs&quot;
  :type=&quot;type&quot;
  class=&quot;mg-button&quot;
  :class=&quot;[
    `mg-button-${variant}-${color}`,
    `mg-button-${size}`,
    { 'mg-button-block': block, 'mg-button-loading': loading },
  ]&quot;
  :disabled=&quot;disabled || loading&quot;
  @click=&quot;handleClick&quot;
&gt;
  &lt;!-- 图标 / 文字 / 加载状态插槽，见第 3 篇完整代码 --&gt;
&lt;/button&gt;
</code></pre>

<h3 id="组件特点">组件特点</h3>

<ul>
<li>无 <code>&lt;style&gt;</code> 块，样式全部来自全局 CSS</li>
<li>完整实现约 110 行，极简清晰（见<a href="./vue-component-api-design">第 3 篇 §九</a>）</li>
<li>类型安全（TypeScript），共享类型从 <code>src/types/components.ts</code> 导入</li>
<li>支持 11 种 props + 3 种插槽，覆盖日常场景</li>
<li><code>v-bind=&quot;$attrs&quot;</code> 透传原生属性</li>
</ul>

<h2 id="构建架构-vite-多入口-独立导出">构建架构：Vite 多入口 + 独立导出</h2>

<p>初版组件库只有一个主入口。但随着组件增多，需要支持<strong>按需引入</strong>——用户只想用 Button 时不应加载全部组件。</p>

<p>v1.5.0 采用 Vite library mode 的多入口构建：</p>

<pre><code class="language-ts">// vite.config.ts（简化）
import { componentNames } from &quot;./scripts/component-list.js&quot;

// 每个组件独立入口（src/exports/&lt;Name&gt;.ts → dist/&lt;kebab&gt;.js）
const componentEntries = Object.fromEntries(
  componentNames.map((name) =&gt; {
    const kebab = name.replace(/([a-z])([A-Z])/g, &quot;$1-$2&quot;).toLowerCase()
    return [`${kebab}`, resolve(__dirname, `src/exports/${name}.ts`)]
  }),
)

export default defineConfig({
  build: {
    lib: {
      entry: {
        index: resolve(__dirname, &quot;src/index.ts&quot;),
        ...componentEntries, // 27 个组件 + 主入口
      },
      formats: [&quot;es&quot;], // 纯 ES Module，无 CJS
    },
    rollupOptions: {
      external: [&quot;vue&quot;], // Vue 作为 peerDependency
      output: {
        assetFileNames: &quot;style.css&quot;, // CSS 统一输出
      },
    },
    cssCodeSplit: false,
  },
})
</code></pre>

<p>对应的 <code>package.json</code> 导出映射：</p>

<pre><code class="language-json">{
  &quot;main&quot;: &quot;./dist/index.js&quot;,
  &quot;module&quot;: &quot;./dist/index.js&quot;,
  &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;types&quot;: &quot;./dist/index.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/index.js&quot;,
      &quot;default&quot;: &quot;./dist/index.js&quot;
    },
    &quot;./style.css&quot;: &quot;./dist/style.css&quot;,
    &quot;./reset.css&quot;: &quot;./dist/reset.css&quot;,
    &quot;./button&quot;: {
      &quot;types&quot;: &quot;./dist/exports/Button.d.ts&quot;,
      &quot;import&quot;: &quot;./dist/button.js&quot;
    },
    &quot;./badge&quot;: { &quot;...&quot;: &quot;...&quot; }
  }
}
</code></pre>

<p>用户既可以使用全量引入 <code>import { Button } from 'moongate-vue'</code>，也可以按需 <code>import Button from 'moongate-vue/button'</code>。</p>

<h2 id="体积控制-25kb-预算-自动化验证">体积控制：25KB 预算 + 自动化验证</h2>

<p>体积是组件库的生命线。为了<strong>不让体积悄悄失控</strong>，我在 <code>pnpm build</code> 后自动执行 <code>scripts/tree-shake-check.js</code>：</p>

<ul>
<li>使用 Vite JS API 将 <code>src/index.ts</code> 打包为单个 ESM bundle（minify）</li>
<li>统计 JS + CSS 的 gzip 体积</li>
<li>如果超过 <strong>25KB 预算</strong>，build 会在 CI 中断言失败</li>
</ul>

<pre><code class="language-bash"># 构建后自动输出（简化示例）
📦 完整库 Min+Gzip：
  ✅ 完整库: 32.50 KB (gzipped 24.80 KB)
      ├─ JS:    22.00 KB (gzipped 9.20 KB)
      └─ CSS:   10.50 KB (gzipped 5.60 KB)

✅ 完整库 Min+Gzip 在 25KB 预算内
</code></pre>

<p>这个&rdquo;预算&rdquo;与文化有关：我用「25KB gzip 完整组件库」作为设计挑战来对抗组件库普遍臃肿的现状。</p>

<h3 id="为什么能这么小">为什么能这么小？</h3>

<ol>
<li><strong>零运行时依赖</strong>：peerDependencies 只有 <code>vue</code>，没有 lodash、async-validator 等</li>
<li><strong>CSS 变量代替 JS 主题系统</strong>：主题切换不需要 JS 集成</li>
<li><strong>组件薄封装</strong>：逻辑极简，组合式函数复用</li>
<li><strong>极少的运行时 JS</strong>：组合式函数复用 + 无运行时依赖</li>
</ol>

<h2 id="微工具类-极简语义工具类">微工具类：极简语义工具类</h2>

<p><code>utilities/</code> 中保留了一套极简的<strong>语义工具类</strong>，直接引用设计令牌：</p>

<pre><code class="language-css">/* 语义颜色工具类 */
.text-primary {
  color: var(--ui-primary);
}
.text-muted {
  color: var(--ui-text-muted);
}
.bg-primary {
  background-color: var(--ui-primary);
}
.bg-muted {
  background-color: var(--ui-bg-muted);
}

/* 核心契约 */
:root {
  --ui-radius: 0px;
  --ui-glow-alpha: var(--ui-physics-glow-alpha-dawn);
}
</code></pre>

<h3 id="特点">特点</h3>

<ul>
<li>只有最常用的 ~20 个类，按需添加</li>
<li>数值绑定设计令牌（<code>var(--ui-*)</code>），保持主题一致</li>
<li>通过 <code>--ui-radius</code>/<code>--ui-glow-alpha</code> 契约变量为全局提供样式锚点</li>
<li>附带 <code>.mg-lunar-halo</code>（月晕阴影效果）等设计系统特有的工具类</li>
<li>布局需求在组件内部通过 scoped 样式解决，工具层不承担布局职责</li>
</ul>

<h2 id="非侵入式样式">非侵入式样式</h2>

<p>组件库在 v1.5.0 明确了<strong>样式非侵入原则</strong>：</p>

<ul>
<li><code>style.css</code> 只包含组件样式，<strong>不会重置你的全局样式</strong></li>
<li>可选引入 <code>moongate-vue/reset.css</code> 统一 <code>box-sizing: border-box</code></li>
</ul>

<pre><code class="language-js">// 只引入组件样式
import &quot;moongate-vue/style.css&quot;

// 或额外引入全局重置（可选）
import &quot;moongate-vue/reset.css&quot;
</code></pre>

<h2 id="体积与维护性分析">体积与维护性分析</h2>

<h3 id="体积数据-v1-5-0-实测口径">体积数据（v1.5.0 实测口径）</h3>

<table>
<thead>
<tr>
<th>类型</th>
<th>原始大小</th>
<th>Gzip 压缩后</th>
</tr>
</thead>

<tbody>
<tr>
<td>CSS（令牌 + 组件样式）</td>
<td>~10.5 KB</td>
<td>~5.6 KB</td>
</tr>

<tr>
<td>JS（完整组件库）</td>
<td>~22 KB</td>
<td>~9.2 KB</td>
</tr>

<tr>
<td><strong>总计</strong></td>
<td><strong>~32.5 KB</strong></td>
<td><strong>~24.8 KB</strong></td>
</tr>
</tbody>
</table>
<p>（实际构建产物以 <code>pnpm build</code> 后的 <code>size-report.json</code> 为准）</p>

<h3 id="维护性对比">维护性对比</h3>

<details>
<summary>📊 完整对比（点击展开）</summary>

| 维度           | 原子化方案（UnoCSS 映射）                | 本方案（CSS 变量 + 薄封装）               |
| -------------- | ---------------------------------------- | ----------------------------------------- |
| **CSS 体积**   | 按需生成，极小                           | ~5.6 KB (gzip)                            |
| **维护成本**   | 需同步映射配置                           | 直接改 CSS                                |
| **心智负担**   | 记忆数百个类名及其映射逻辑               | 只需 ~20 个组件类名                       |
| **可读性**     | 模板臃肿，难以一眼看出组件层级           | 模板极简，类名语义化清晰                  |
| **首屏渲染**   | 需等待 JS 注入样式                       | 纯 CSS，浏览器原生渲染                    |
| **运行环境**   | 需要 Node + PostCSS/Vite 插件 + 配置文件 | 只需浏览器支持 CSS Variables（98%+ 环境） |
| **多框架复用** | 不可能                                   | 样式文件可跨框架                          |
| **按需引入**   | -                                        | 27 个独立导出入口（v1.5.0）               |
| **体积预算**   | -                                        | 25KB gzip 强制验证（CI 中断）             |

</details>

<h4 id="核心差异一句话">核心差异一句话</h4>

<p>原子化方案赢在体积，本方案赢在维护成本、可读性和多框架复用——对组件库来说，后者更重要。</p>

<h2 id="总结">总结</h2>

<h3 id="适用场景">适用场景</h3>

<ul>
<li>✅ 已有成熟设计令牌的项目</li>
<li>✅ 追求极致体积（gzip &lt; 25KB）的组件库</li>
<li>✅ 需要按需引入的构建场景（Vite multi-entry）</li>
<li>✅ 不希望引入复杂工具链的场景</li>
</ul>

<h3 id="不适用场景">不适用场景</h3>

<ul>
<li>❌ 从零开始、没有设计令牌的项目</li>
<li>❌ 需要动态主题切换的大型设计系统（需 JS 主题引擎）</li>
<li>❌ 需要大量业务组件（DatePicker、Tree 等）</li>
</ul>

<h3 id="核心收获">核心收获</h3>

<p>初版的 10KB 承诺在 v1.5.0 经过多轮功能迭代（全局 i18n 文案、可搜索 Select、多选、无障碍增强、450 测试），依然保持 <strong>25KB gzip 预算内</strong>——这不是偶然，而是通过<strong>架构纪律</strong>（零依赖 + 薄封装）和<strong>自动化</strong>（tree-shake-check.js 体积门禁）共同守住的。</p>

<p><strong>这 25KB 不仅是体积的缩减，更是思维的减负。</strong></p>

<hr>

<h2 id="关于-moongate-vue">🌙 关于 Moongate Vue</h2>

<p>本文来自 Moongate Vue 组件库设计实战系列（共 4 篇），所有内容均基于真实项目实践：</p>

<ul>
<li><strong>项目仓库</strong>：<a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">github.com/yuelinghuashu/moongate-vue</a> — 极简 Vue 3 组件库，零依赖、CSS 优先、25KB gzip</li>
<li><strong>真实案例</strong>：<a href="https://moongate.top" target="_blank">moongate.top</a> — 个人博客，从 Nuxt UI v4 迁移至 Moongate Vue 构建</li>
<li><strong>在线文档</strong>：<a href="https://vue.moongate.top" target="_blank">vue.moongate.top</a> — 组件 API 与主题定制指南</li>
</ul>
]]></content:encoded>
      <description><![CDATA[设计令牌驱动的 Vue 3 组件库架构实录。四层 CSS 架构、极简 Vue 组件、Vite 多入口构建、体积预算自动化验证，展示如何保持组件库在 25KB (gzip) 内的工程实践。]]></description>
      <category><![CDATA[CSS]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[当设计令牌遇上原子化CSS：一次整合失败的反思与融合之道]]></title>
      <link>https://moongate.top/docs/design-tokens-vs-atomic-css</link>
      <guid isPermaLink="true">https://moongate.top/docs/design-tokens-vs-atomic-css</guid>
      <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>从 UnoCSS 映射设计令牌的失败经历出发，量化对比两种方案的维护成本，给出务实的分工边界</p>
</blockquote>

<h2 id="一-起点-我有一套完整的设计令牌">一、起点：我有一套完整的设计令牌</h2>

<p>我的个人项目 <code>moongate-vue</code> 的样式系统由三组文件构成：</p>

<ul>
<li><strong><code>src/styles/tokens/colors.css</code></strong>：浅色/深色双主题，语义化颜色变量（<code>--ui-primary</code>、<code>--ui-bg-muted</code>……），以及布局令牌</li>
<li><strong><code>src/styles/tokens/layout.css</code></strong>：间距、圆角、动效时长、字体、断点、z-index 等布局令牌</li>
<li><strong><code>src/styles/index.css</code></strong>：全局样式入口，导入令牌 + 组件样式 + 极简工具类</li>
</ul>

<p>这套令牌系统不依赖任何框架，任何组件都可以通过 <code>var(--ui-spacing-md)</code>、<code>var(--ui-primary)</code> 获取设计约束。它稳定、直观、可维护，是我整个组件库的<strong>地基</strong>。</p>

<p>关于变量规模，需要诚实说明：<code>colors.css</code> 浅色/深色模式各定义了 <strong>68 个颜色变量</strong>（远超初版的 40+）。其中一部分是组件库实际消费的 UI 令牌，另一部分是沿袭自 <code>moongate-theme</code> 项目的<strong>编辑器主题扩展变量</strong>——例如 <code>--ui-ansi-red</code>（ANSI 终端色）、<code>--ui-bracket1</code>（括号对色）、<code>--ui-git-added</code>（Git 状态色）、<code>--ui-debug-start</code>（调试断点色）等。</p>

<p>这些变量是我个人的编辑器主题项目（moongate-theme）所需的，虽然组件库自身不消费它们，但它们确实存在于令牌文件中——这是我通过&rdquo;生成脚本自动产出完整令牌集&rdquo;而非&rdquo;只产出组件所需子集&rdquo;的选择。<strong>它们不是冗余代码，只是个人其他项目的设计资产共享。</strong></p>

<h2 id="二-诱惑-unocss-的轻量与原子化">二、诱惑：UnoCSS 的轻量与原子化</h2>

<p>我听过 Tailwind 的&rdquo;笨重&rdquo;名声，但 UnoCSS 号称按需生成、零运行时、超轻量。作为个人开发者，我渴望&rdquo;不用写 CSS 类名&rdquo;的体验——直接在模板里堆 <code>flex p-4 text-center</code>，不用切文件，不用想命名。</p>

<p>于是某个夜晚，我决定：把设计令牌&rdquo;映射&rdquo;到 UnoCSS 上，既保留设计系统，又享受原子化书写。</p>

<h2 id="三-碰撞-一个晚上的挣扎-附真实配置">三、碰撞：一个晚上的挣扎（附真实配置）</h2>

<p>我写了 <code>uno.config.ts</code>，试图把每个 <code>--ui-*</code> 变量映射成原子类。以下是我当时失败的部分配置：</p>

<pre><code class="language-ts">// uno.config.ts（失败版本）
import { defineConfig } from &quot;unocss&quot;

export default defineConfig({
  theme: {
    colors: {
      // ❌ 每个 --ui-* 变量都要手动映射一次，68 个变量 = 68 行映射
      // ❌ 变量改一处，这里也要跟着改，单一真实源变成两个
      primary: &quot;var(--ui-primary)&quot;,
      success: &quot;var(--ui-success)&quot;,
      warning: &quot;var(--ui-warning)&quot;,
      error: &quot;var(--ui-error)&quot;,
      // ❌ 命名开始失控：bg-muted / border-subtle 拼出来的类名像口吃
      &quot;bg-muted&quot;: &quot;var(--ui-bg-muted)&quot;,
      &quot;border-subtle&quot;: &quot;var(--ui-border-subtle)&quot;,
      // 需要映射 68 个变量，此处省略...
    },
    spacing: {
      xs: &quot;var(--ui-spacing-xs)&quot;,
      sm: &quot;var(--ui-spacing-sm)&quot;,
      md: &quot;var(--ui-spacing-md)&quot;,
      lg: &quot;var(--ui-spacing-lg)&quot;,
      xl: &quot;var(--ui-spacing-xl)&quot;,
      &quot;2xl&quot;: &quot;var(--ui-spacing-2xl)&quot;,
      &quot;3xl&quot;: &quot;var(--ui-spacing-3xl)&quot;,
    },
    borderRadius: {
      none: &quot;var(--ui-radius-none)&quot;,
      sm: &quot;var(--ui-radius-sm)&quot;,
    },
  },
})
</code></pre>

<p>然后在 <code>Button.vue</code> 里把原来的 scoped 样式全部替换成原子类：</p>

<pre><code class="language-vue">&lt;!-- 改造后的 Button.vue（失败尝试） --&gt;
&lt;!-- ❌ 语义丢失：一眼看不出这是按钮的主要样式，只能看到一串布局碎片 --&gt;
&lt;button :class=&quot;cn('bg-primary text-white', 'bg-bg-muted', 'border-border-subtle')&quot;&gt;
</code></pre>

<p>问题很快暴露：</p>

<ul>
<li><strong>类名冗余且不语义化</strong>：<code>bg-bg-muted</code>、<code>border-border-subtle</code>，读起来像口吃。</li>
<li><strong>映射维护成本高</strong>：CSS 变量改一处，UnoCSS 配置也要改，单一真实源变成两个。</li>
<li><strong>条件逻辑爆炸</strong>：为了处理不同颜色变体，得写 <code>color === 'neutral' ? 'text-dim' : 'text-'+color</code>。</li>
<li><strong>调试困难</strong>：浏览器里看到 <code>bg-bg-muted</code>，得反向查找它对应哪个 CSS 变量。</li>
<li><strong>IDE 无补全</strong>：UnoCSS 的类型生成无法自动覆盖我的自定义变量名，导致开发时毫无提示。</li>
</ul>

<p>当晚我就删掉了所有映射代码，回到了原始方案。</p>

<h2 id="四-顿悟-设计令牌就是最轻量的原子化框架">四、顿悟：设计令牌就是最轻量的原子化框架</h2>

<p>看着 <code>bg-bg-muted</code>，我突然问自己：<strong>为什么不直接写 <code>background-color: var(--ui-bg-muted)</code> 呢？</strong></p>

<p>我的设计令牌系统本身已经提供了所有设计约束。UnoCSS 宣称的&rdquo;设计约束&rdquo;能力，我的 CSS 变量全都有。它剩下的唯一价值就是&rdquo;快速书写&rdquo;——即语法糖。</p>

<p>更深一层：<strong>把 <code>--ui-primary</code> 映射成 <code>bg-primary</code>，本质上是在&rdquo;用 CSS 封装 CSS&rdquo;</strong>。UnoCSS 在编译阶段生成 <code>.bg-primary { background-color: var(--ui-primary); }</code> 这样的代码。既然最终都是这行 CSS，我直接在 scoped 样式里写它，不是更直接吗？</p>

<h2 id="五-量化对比-纯设计令牌方案-vs-unocss-映射方案">五、量化对比：纯设计令牌方案 vs. UnoCSS 映射方案</h2>

<p>为了客观判断&rdquo;不划算&rdquo;到底多不划算，我整理了对比表（基于我的项目实测，令牌数按实际 68 统计）：</p>

<details>
<summary>📊 完整量化对比（点击展开）</summary>

| 指标                   | 纯设计令牌方案（最终采用）               | UnoCSS 映射方案（放弃）                      |
| ---------------------- | ---------------------------------------- | -------------------------------------------- |
| CSS 变量数量           | 68（浅/深各 68）                         | 68（不变）                                   |
| 额外配置文件行数       | 0                                        | ~200 行（`uno.config.ts`，含 68 个颜色映射） |
| 组件模板中类名长度     | 短（`mg-button`）                        | 长（`bg-primary text-white rounded`）        |
| 修改一个颜色需要改几处 | 1 处（CSS 变量定义）                     | 2 处（CSS 变量 + UnoCSS 映射）               |
| TypeScript 支持        | 原生 CSS 变量无提示                      | 可通过类型生成获得，但需额外配置             |
| 首屏 CSS 体积（gzip）  | ~4 KB（组件库实际消费）                  | ~2 KB（按需生成更小）                        |
| 调试体验               | 直接看到 `background: var(--ui-primary)` | 需要查找 `bg-primary` 映射到哪个变量         |
| 学习成本（新人）       | 低（只需理解 CSS 变量）                  | 中（需理解映射逻辑 + UnoCSS 规则）           |

</details>

<h3 id="结论">结论</h3>

<p>牺牲 ~2 KB 体积，换取维护成本的巨大降低。对于个人项目，<strong>维护成本、语义清晰度、调试体验</strong>比极致的体积优化更重要。</p>

<blockquote>
<p>💡 关于&rdquo;68 个变量&rdquo;的说明：组件库实际消费的 UI 令牌大约一半，另一半是编辑器主题扩展变量（ANSI 色、括号对色等，用于个人主题项目共享）。UnoCSS 映射方案需要为全部 68 个变量维护映射，这恰恰放大了映射成本——而直接使用 CSS 变量则天然支持&rdquo;只引用需要的变量&rdquo;。</p>
</blockquote>

<h2 id="六-融合之道-务实的边界">六、融合之道：务实的边界</h2>

<p>我的经历并不证明原子化 CSS 不好，而是证明：<strong>当你的项目已经拥有一套成熟的设计令牌时，强行把令牌映射成原子类是多余的</strong>。</p>

<p>但这不等于要完全放弃原子化工具。我后来找到了合理的分工，关键是<strong>区分&rdquo;与数值无关的类&rdquo;和&rdquo;与数值相关的类&rdquo;</strong>：</p>

<table>
<thead>
<tr>
<th>样式类型</th>
<th>推荐方案</th>
<th>理由</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>布局</strong>（flex、grid、position）</td>
<td>UnoCSS 原子类（<code>flex</code>、<code>grid</code>、<code>items-center</code>、<code>justify-between</code>、<code>relative</code>）</td>
<td>不涉及数值，无需令牌，开箱即用</td>
</tr>

<tr>
<td><strong>间距</strong>（padding、margin、gap）</td>
<td><strong>优先 scoped 样式 + <code>var(--ui-spacing-*)</code></strong></td>
<td>保持主题一致性。如果非要用原子类，必须修改 UnoCSS 配置将数值映射到你的令牌（见下方案例）</td>
</tr>

<tr>
<td><strong>颜色、圆角、阴影、动效</strong></td>
<td>一律 scoped 样式 + CSS 变量</td>
<td>语义化强，无映射成本，调试直观</td>
</tr>

<tr>
<td><strong>响应式变体</strong></td>
<td>可使用 UnoCSS 的 <code>md:</code> 前缀，但只用于布局/间距类，不用于颜色</td>
<td>简洁且不污染设计令牌</td>
</tr>
</tbody>
</table>

<h3 id="关于间距的特殊说明">关于间距的特殊说明</h3>

<p>UnoCSS 默认的 <code>p-4</code> 对应 <code>1rem</code>，而你的设计令牌中可能是 <code>--ui-spacing-md: 12px</code>。如果直接写 <code>p-4</code>，就会绕过设计系统。如果你真的想用原子类的间距，必须修改配置：</p>

<pre><code class="language-ts">// uno.config.ts（仅当你想用原子间距时）
theme: {
  spacing: {
    sm: 'var(--ui-spacing-sm)',
    md: 'var(--ui-spacing-md)',
    lg: 'var(--ui-spacing-lg)',
  }
}
</code></pre>

<p>然后你就可以写 <code>p-sm</code>、<code>m-md</code>。但注意：这又回到了映射维护的问题——每次修改令牌值，都要同步更新 UnoCSS 配置。<strong>我最终选择完全不使用原子间距类，只使用无数值的布局类</strong>。</p>

<h2 id="七-如果没有设计令牌-应该用原子化吗">七、如果没有设计令牌，应该用原子化吗？</h2>

<p>我并不是原子化 CSS 的反对者。如果以下条件满足，我会毫不犹豫选择 UnoCSS/Tailwind：</p>

<ul>
<li>✅ 新项目、快速原型</li>
<li>✅ 设计系统尚未成熟，还在快速迭代</li>
<li>✅ 团队全员熟悉原子化语法</li>
<li>✅ 不需要跨框架复用样式</li>
</ul>

<h3 id="不适用场景">不适用场景</h3>

<ul>
<li>❌ 已有成熟设计令牌且需要长期维护</li>
<li>❌ 需要样式文件跨 Vue/React/Svelte 复用</li>
<li>❌ 对 CSS 体积不敏感（现代原子化框架其实很小，这不是主要矛盾）</li>
</ul>

<h2 id="八-给同样处境的开发者建议">八、给同样处境的开发者建议</h2>

<p>如果你和我一样，已经有一套完整的设计令牌系统（CSS 变量、Design Tokens），但想尝试原子化 CSS，我的建议是：</p>

<ol>
<li><strong>不要映射颜色、主题间距、圆角等核心令牌</strong>。这些应该直接通过 <code>var(--ui-*)</code> 在 scoped 样式中使用。</li>
<li><strong>只使用原子化工具提供的通用布局类</strong>（<code>flex</code>, <code>grid</code>, <code>items-center</code>, <code>relative</code>……）。这些与你的设计系统无关，且无需任何映射。</li>
<li><strong>警惕&rdquo;魔法数字&rdquo;</strong>：避免在模板里写 <code>p-3.5</code> 或 <code>gap-11</code> 这种硬编码值，它们会破坏设计令牌的契约。如果你发现自己经常需要偏离令牌系统，说明你的令牌定义可能不够灵活，需要扩展而非绕过。</li>
<li><strong>对于响应式布局</strong>，可以继续使用原子化工具的响应式变体（<code>md:flex</code>），但尽量只用于布局，不用于颜色/间距。</li>
<li><strong>如果原子化工具让你觉得&rdquo;为了用而用&rdquo;，完全可以不用</strong>。原生 CSS + 设计令牌已经足够清晰、可维护。</li>
</ol>

<h2 id="九-后续演进-dtcg-与未来">九、后续演进：DTCG 与未来</h2>

<p>值得一提的是，W3C 旗下的设计令牌社区组（DTCG）已在 2025 年底发布首个稳定规范，推动设计令牌成为跨平台通用语言。这意味着&rdquo;以令牌为中心&rdquo;的架构，正成为行业共识。</p>

<p>原子化工具可以消费设计令牌，但不应绑架设计令牌。将令牌硬编码成特定工具的原子类，相当于把自己的设计系统锁死在该工具的语法上。而直接使用 CSS 变量，则是框架无关、面向未来的选择。</p>

<p>未来，当 DTCG 工具链成熟后，可能会自动从设计令牌生成原子类，届时我会重新评估 UnoCSS。但目前（2026），手动映射仍是维护负担。</p>

<h2 id="十-结论-设计令牌优先-原子化可选">十、结论：设计令牌优先，原子化可选</h2>

<p>最终，我的 <code>Button.vue</code> 回到了最原始的样子：</p>

<pre><code class="language-css">/* src/styles/components/button.css */
.mg-button-filled-primary {
  background-color: var(--ui-primary);
  color: white;
}
.mg-button-filled-primary:hover:not(:disabled) {
  background-color: color-mix(in srgb, var(--ui-primary), black 10%);
}
</code></pre>

<p>简单、直接、语义化。这才是设计令牌该有的用法。</p>

<p>UnoCSS 和 Tailwind 是好工具，但它们不是设计系统的替代品。设计令牌才是地基，原子化只是上面的一层涂料。当你已经有一块坚实的地基时，是否涂上这层涂料，取决于你愿不愿意接受那点语法糖带来的维护成本。</p>

<p>至少对我来说，<strong>不划算</strong>。</p>

<hr>

<h2 id="关于-moongate-vue">🌙 关于 Moongate Vue</h2>

<p>本文来自 Moongate Vue 组件库设计实战系列（共 4 篇），所有内容均基于真实项目实践：</p>

<ul>
<li><strong>项目仓库</strong>：<a href="https://github.com/yuelinghuashu/moongate-vue" target="_blank">github.com/yuelinghuashu/moongate-vue</a> — 极简 Vue 3 组件库，零依赖、CSS 优先、25KB gzip</li>
<li><strong>真实案例</strong>：<a href="https://moongate.top" target="_blank">moongate.top</a> — 个人博客，从 Nuxt UI v4 迁移至 Moongate Vue 构建</li>
<li><strong>在线文档</strong>：<a href="https://vue.moongate.top" target="_blank">vue.moongate.top</a> — 组件 API 与主题定制指南</li>
</ul>
]]></content:encoded>
      <description><![CDATA[当个人开发者尝试用 UnoCSS 映射已有设计令牌失败后，反思工具迷信，提出设计令牌优先于原子化 CSS 的架构观点，并探索两者融合的务实边界。]]></description>
      <category><![CDATA[CSS]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Design System]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:moongate-vue]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[用原生 <details> 实现系列折叠页：从“点两次”到“稳定可控”]]></title>
      <link>https://moongate.top/docs/series-collapse-with-details</link>
      <guid isPermaLink="true">https://moongate.top/docs/series-collapse-with-details</guid>
      <pubDate>Tue, 24 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>个人博客的系列页面，我选择了原生 <code>&lt;details&gt;</code> 元素来实现折叠列表。本以为是最简单直接的方式，却在添加“全部折叠/展开”按钮时遇到了“点两次”的诡异问题。这篇文章记录了排查和解决的过程，也让我对原生 DOM 与状态同步有了更深的理解。</p>
</blockquote>

<hr>

<h2 id="1-背景与需求">1. 背景与需求</h2>

<p>我的博客最近新增了“系列”页面，用于按主题聚合文章（如“URL状态同步”、“设计系统”等）。每个系列包含多篇文章，默认折叠，用户点击系列标题可展开查看该系列下的文章列表。同时，页面右上角需要一个按钮，能够<strong>一键折叠/展开所有系列</strong>。</p>

<p>理想很朴素：用原生 <code>&lt;details&gt;</code> 和 <code>&lt;summary&gt;</code> 实现折叠面板，再写几行 JavaScript 控制全局按钮。但实际实现中，我遇到了一个典型问题：<strong>点击全局按钮时，需要点击两次才能完成切换</strong>。下面记录完整的实现过程与解决方案。</p>

<hr>

<h2 id="2-初始实现-简单的-details-循环">2. 初始实现：简单的 <code>&lt;details&gt;</code> 循环</h2>

<p>首先，我从数据库中获取所有文章，按系列分组，渲染成一个 <code>&lt;details&gt;</code> 列表。每个系列标题显示系列名和文章数量，内部展示文章标题、等级和日期。</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;max-w-3xl mx-auto&quot;&gt;
    &lt;div v-for=&quot;series in seriesList&quot; :key=&quot;series.slug&quot; class=&quot;mb-4&quot;&gt;
      &lt;details&gt;
        &lt;summary class=&quot;flex items-center gap-2 cursor-pointer&quot;&gt;
          &lt;span&gt;{{ series.name }}&lt;/span&gt;
          &lt;span class=&quot;text-sm text-gray-500&quot;&gt;({{ series.docs.length }})&lt;/span&gt;
        &lt;/summary&gt;
        &lt;div class=&quot;pl-4 mt-2 space-y-2&quot;&gt;
          &lt;div v-for=&quot;article in series.docs&quot; :key=&quot;article.id&quot;&gt;
            &lt;NuxtLink :to=&quot;article.path&quot; class=&quot;text-blue-600 hover:underline&quot;&gt;
              {{ article.title }}
            &lt;/NuxtLink&gt;
            &lt;div class=&quot;text-xs text-gray-500&quot;&gt;
              {{ article.level }} · {{ formatDate(article.date) }}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/details&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p>这一步一切正常，每个系列都可以独立展开/折叠。</p>

<hr>

<h2 id="3-添加-全部折叠-展开-按钮">3. 添加“全部折叠/展开”按钮</h2>

<p>为了实现全局控制，我需要一个变量来记录当前是否有系列处于展开状态，并据此决定按钮的行为（全部折叠或全部展开）。</p>

<pre><code class="language-ts">const isAnyExpanded = ref(false)

// 更新全局状态：遍历所有 &lt;details&gt;，检查是否有 open 属性为 true
const updateAnyExpanded = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;)
  isAnyExpanded.value = Array.from(details).some((detail) =&gt; detail.open)
}

// 全部折叠/展开
const toggleAll = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;)
  const shouldExpand = !isAnyExpanded.value
  details.forEach((detail) =&gt; {
    detail.open = shouldExpand
  })
  updateAnyExpanded() // 更新状态
}
</code></pre>

<h3 id="问题出现了">问题出现了</h3>

<p>点击“全部折叠/展开”按钮时，出现了“要点<strong>两次</strong>才生效”的诡异问题：第一次点击似乎没有反应，第二次才正确切换所有系列的状态。</p>

<p>先看初版代码的缺口：<code>isAnyExpanded</code> 只在 <code>toggleAll</code>（以及它调用的 <code>updateAnyExpanded</code>）里被更新，而用户<strong>手动点击 <code>&lt;summary&gt;</code> 时并不会</strong>更新它。一旦手动展开/折叠过某个系列，按钮依赖的“当前是否有系列展开”就已经和真实 DOM <strong>脱节</strong>：下一次点击“全部折叠/展开”会基于这个<strong>过期状态</strong>算出错误的目标动作，看起来“没反应”；要再点一次（此时状态已被上一次操作修正）才生效。</p>

<p>于是我想当然地加了一层全局 <code>toggle</code> 事件监听，想把手动点击也同步进来——结果问题变得更隐蔽，见下一节。</p>

<hr>

<h2 id="4-问题定位-两套状态同步机制互相打架">4. 问题定位：两套状态同步机制互相打架</h2>

<p>为什么会出现“点两次”？先厘清两个平台事实：</p>

<ol>
<li><strong><code>toggle</code> 事件是异步的，而且任何 <code>open</code> 变化都会触发它</strong>。无论用户点击 <code>&lt;summary&gt;</code>，还是 JavaScript 直接修改 <code>open</code> 属性，只要 <code>open</code> 状态发生改变，浏览器就会<strong>排队一个任务</strong>去触发 <code>toggle</code> 事件。也就是说，程序化修改同样会触发 <code>toggle</code>，只是<strong>异步</strong>触发；并且该事件<strong>不冒泡、不可取消</strong>。</li>
<li><strong><code>&lt;summary&gt;</code> 的点击默认动作在事件派发结束后才同步执行</strong>。点击 <code>&lt;summary&gt;</code> 时，浏览器先派发 <code>click</code> 事件（此刻 <code>open</code> 仍是旧值），派发结束后才<strong>同步</strong>执行默认动作去切换 <code>open</code>。因此在 <code>@click</code> 处理器内部同步读取 <code>detail.open</code>，读到的必然是<strong>切换前</strong>的旧值。</li>
</ol>

<p>回到我的实现。我在 <code>onMounted</code> 里加了全局 <code>toggle</code> 事件监听，试图把手动点击的状态同步给按钮。但这套“事件同步”与 <code>toggleAll</code> 的“直接写状态”两套机制开始互相打架：</p>

<ul>
<li><code>toggleAll</code> 批量设置所有 <code>open</code> 后，<strong>立即</strong>把 <code>isAnyExpanded</code> 写成目标值；</li>
<li>同一批修改会让浏览器为<strong>每一个</strong> <code>&lt;details&gt;</code>（程序化变化同样会触发）<strong>排队一个异步 <code>toggle</code> 回调</strong>，N 个元素就是 N 个回调；</li>
<li>这些异步回调随后逐个执行、再次写入状态，与 <code>toggleAll</code> 里刚写好的值交错覆盖——最终按钮的意图和真实 DOM 失步，表现就是“要点两次才生效”。</li>
</ul>

<p>简单来说：<strong>批量操作的“直接同步”与手动点击的“事件同步”混在一起，异步回调把状态覆盖回了错误的值</strong>。要解决它，只需要保留一套同步机制。</p>

<hr>

<h2 id="5-解决方案-放弃事件监听-直接同步状态">5. 解决方案：放弃事件监听，直接同步状态</h2>

<p>既然 <code>toggle</code> 事件会干扰批量操作，我决定<strong>完全放弃监听 <code>toggle</code> 事件</strong>，改为：</p>

<ul>
<li>批量操作时，直接根据目标状态设置所有 <code>&lt;details&gt;</code> 的 <code>open</code>，并<strong>立即将状态变量设为目标值</strong>，不再依赖 DOM 查询。</li>
<li>手动点击时，通过 <code>&lt;summary&gt;</code> 的 <code>click</code> 事件，用 <code>setTimeout</code> 把读取推迟到 <code>click</code> 默认动作<strong>执行完之后</strong>——此时 <code>open</code> 才是切换后的新值。</li>
</ul>

<h3 id="修改后的-toggleall-函数">修改后的 <code>toggleAll</code> 函数</h3>

<pre><code class="language-ts">const toggleAll = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;)
  const shouldExpand = !isAnyExpanded.value // 目标状态
  details.forEach((detail) =&gt; {
    detail.open = shouldExpand
  })
  // 直接根据本次意图设置状态，不再调用 updateAnyExpanded
  isAnyExpanded.value = shouldExpand
}
</code></pre>

<h3 id="手动点击时的状态同步">手动点击时的状态同步</h3>

<p>为 <code>&lt;summary&gt;</code> 添加 <code>@click=&quot;onSummaryClick&quot;</code>，在回调中使用 <code>setTimeout</code> 延迟到下一个事件循环再读取 DOM 状态。</p>

<pre><code class="language-ts">const onSummaryClick = () =&gt; {
  setTimeout(() =&gt; {
    updateAnyExpanded()
  }, 0)
}
</code></pre>

<p>模板中的 <code>&lt;summary&gt;</code>：</p>

<pre><code class="language-vue">&lt;summary class=&quot;flex items-center gap-2 cursor-pointer&quot; @click=&quot;onSummaryClick&quot;&gt;
  &lt;span&gt;{{ series.name }}&lt;/span&gt;
  &lt;span class=&quot;text-sm text-gray-500&quot;&gt;({{ series.docs.length }})&lt;/span&gt;
&lt;/summary&gt;
</code></pre>

<h3 id="组件挂载时初始化状态">组件挂载时初始化状态</h3>

<pre><code class="language-ts">onMounted(() =&gt; {
  updateAnyExpanded()
})
</code></pre>

<h4 id="为什么用-settimeout-0">为什么用 <code>setTimeout(..., 0)</code>？</h4>

<p>点击 <code>&lt;summary&gt;</code> 后的事件顺序是：浏览器先派发 <code>click</code> 事件（此刻 <code>open</code> 仍是旧值），派发结束<strong>同步</strong>执行默认动作切换 <code>open</code>，之后 <code>toggle</code> 事件才被<strong>异步</strong>触发。<code>setTimeout(0)</code> 把读取推迟到下一个宏任务——此时默认动作早已执行完毕，读到的必然是切换后的 <code>open</code>。</p>

<p>这里的要点是：<strong>不要用 <code>toggle</code> 事件来同步状态</strong>。它不但异步触发，而且与 <code>setTimeout</code> 回调之间没有可靠的先后顺序，依赖它去“修正”状态必然引入竞态——这正是上一节“点两次”问题的根源。只保留“点击后延迟读一次 DOM”这一套机制，状态就永远是准的。</p>

<blockquote>
<p>如果用户快速连续点击同一个 <code>&lt;summary&gt;</code>，多个 <code>setTimeout</code> 会排队执行，可能导致 <code>updateAnyExpanded</code> 被多次调用。虽然最终状态正确，但会有不必要的性能开销。由于系列页面的交互频率极低，这个影响可以忽略；如果希望更严谨，可以增加一个简单的防抖函数，但当前实现已经足够稳定。</p>
</blockquote>

<hr>

<h2 id="6-最终代码">6. 最终代码</h2>

<p>以下是完整的系列页面代码，包含数据获取、分组、折叠控制及样式。该实现假设页面在加载后不会动态增减 <code>&lt;details&gt;</code>（符合博客系列页的静态特性），因此状态同步逻辑简单可靠。</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;max-w-3xl mx-auto&quot;&gt;
    &lt;div class=&quot;flex justify-end items-center mb-6&quot;&gt;
      &lt;UButton
        :ui=&quot;{ leadingIcon: 'toolbar-icon-btn' }&quot;
        class=&quot;cursor-pointer&quot;
        variant=&quot;ghost&quot;
        :icon=&quot;isAnyExpanded ? 'lucide-chevrons-up' : 'lucide-chevrons-down'&quot;
        @click=&quot;toggleAll&quot;
      /&gt;
    &lt;/div&gt;

    &lt;div v-for=&quot;series in seriesList&quot; :key=&quot;series.slug&quot; class=&quot;mb-4&quot;&gt;
      &lt;details&gt;
        &lt;summary
          class=&quot;flex items-center gap-2 cursor-pointer&quot;
          @click=&quot;onSummaryClick&quot;
        &gt;
          &lt;span&gt;{{ series.name }}&lt;/span&gt;
          &lt;span class=&quot;text-sm text-gray-500&quot;&gt;({{ series.docs.length }})&lt;/span&gt;
        &lt;/summary&gt;
        &lt;div class=&quot;pl-4 mt-2 space-y-2&quot;&gt;
          &lt;div v-for=&quot;article in series.docs&quot; :key=&quot;article.id&quot;&gt;
            &lt;NuxtLink :to=&quot;article.path&quot; class=&quot;text-blue-600 hover:underline&quot;&gt;
              {{ article.title }}
            &lt;/NuxtLink&gt;
            &lt;div class=&quot;text-xs text-gray-500&quot;&gt;
              {{ article.level }} · {{ formatDate(article.date) }}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/details&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup lang=&quot;ts&quot;&gt;
import dayjs from &quot;dayjs&quot;

const { tm } = useI18nSafe()

// ==================== 数据获取与分组 ====================
const { data: docsList } = await useAsyncData(&quot;series&quot;, () =&gt; {
  return queryCollection(&quot;docs&quot;)
    .order(&quot;date&quot;, &quot;ASC&quot;)
    .select(&quot;id&quot;, &quot;series&quot;, &quot;title&quot;, &quot;level&quot;, &quot;path&quot;, &quot;seo&quot;, &quot;date&quot;)
    .all()
})

// 将文档按 series 分组
const seriesMap = computed(() =&gt; {
  const map = new Map&lt;string, typeof docsList.value&gt;()
  if (!docsList.value) return map
  for (const doc of docsList.value) {
    if (!doc.series) continue
    if (!map.has(doc.series)) map.set(doc.series, [])
    map.get(doc.series)!.push(doc)
  }
  return map
})

// 系列列表（从 i18n 获取系列名）
const seriesList = computed(() =&gt; {
  const seriesObj = tm(&quot;series&quot;) as Record&lt;string, string&gt;
  return Object.entries(seriesObj).map(([slug, name]) =&gt; ({
    slug,
    name,
    docs: seriesMap.value.get(slug) || [],
  }))
})

// ==================== 折叠/展开所有系列 ====================

// 记录当前是否有任何系列处于展开状态，用于动态切换按钮图标
const isAnyExpanded = ref(false)

/**
 * 更新全局展开状态
 * 直接遍历 DOM 中所有 &lt;details&gt; 元素，检查是否有任一展开
 * 该函数仅在手动点击 summary 时调用，用于同步按钮状态
 */
const updateAnyExpanded = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;)
  isAnyExpanded.value = Array.from(details).some((detail) =&gt; detail.open)
}

/**
 * 切换所有系列的展开/折叠状态
 * 由右上角按钮触发
 * 1. 根据当前 isAnyExpanded 计算目标状态（全部展开或全部折叠）
 * 2. 批量设置所有 &lt;details&gt; 的 open 属性
 * 3. 直接更新 isAnyExpanded 为目标状态，无需再次查询 DOM
 */
const toggleAll = () =&gt; {
  const details = document.querySelectorAll(&quot;details&quot;)
  const shouldExpand = !isAnyExpanded.value // 目标状态：当前全部折叠则展开，否则折叠
  details.forEach((detail) =&gt; {
    detail.open = shouldExpand
  })
  // 直接根据本次操作意图设置状态，避免因 toggle 事件干扰而需要两次点击
  isAnyExpanded.value = shouldExpand
}

/**
 * 用户手动点击 summary（系列标题）时的回调
 * click 事件的默认动作会在事件派发结束后才切换 &lt;details&gt; 的 open，
 * 处理器内同步读取到的仍是旧值；因此推迟到 setTimeout 后再读取，
 * 此时 open 已是切换后的新值，据此同步按钮图标
 */
const onSummaryClick = () =&gt; {
  setTimeout(() =&gt; {
    updateAnyExpanded()
  }, 0)
}

/**
 * 组件挂载后，初始化全局展开状态（页面加载时所有 &lt;details&gt; 默认为折叠）
 */
onMounted(() =&gt; {
  updateAnyExpanded()
})

// ==================== 辅助函数 ====================
const formatDate = (date: string) =&gt; dayjs(date).format(&quot;YYYY-MM-DD&quot;)
&lt;/script&gt;

&lt;style scoped&gt;
/* 隐藏 details 默认的三角形图标（与 flex 布局无关，确保所有浏览器都隐藏） */
details &gt; summary {
  list-style: none;
}
details &gt; summary::-webkit-details-marker {
  display: none;
}
&lt;/style&gt;
</code></pre>

<blockquote>
<p><strong>注</strong>：本实现假设页面中的 <code>&lt;details&gt;</code> 元素数量在加载后不会发生变化（符合博客系列页的静态特性）。如果后续通过异步操作动态增删系列，则需要对 <code>isAnyExpanded</code> 的同步逻辑做额外处理（例如在增删时手动调用 <code>updateAnyExpanded</code>）。不过对个人博客而言，当前实现已足够稳定。</p>
</blockquote>

<hr>

<h2 id="7-思考与总结">7. 思考与总结</h2>

<h3 id="为什么不用-ui-库的手风琴组件">为什么不用 UI 库的手风琴组件？</h3>

<p>我的博客追求极简风格，不希望引入太多依赖。原生 <code>&lt;details&gt;</code> 足以满足基础折叠功能，且代码量少，完全可控。虽然官方组件（如 Nuxt UI 的 <code>UAccordion</code>）功能更强大（动画、多选、无障碍），但对个人博客而言，<strong>够用就好</strong>。</p>

<h3 id="这次踩坑的收获">这次踩坑的收获</h3>

<ul>
<li><strong>批量操作原生 DOM 时，要警惕其触发的事件对状态的影响</strong>。批量修改时，直接同步状态变量比依赖 DOM 事件更可靠。</li>
<li><strong>用 <code>setTimeout(..., 0)</code> 等待 DOM 更新后再读取状态是常见模式</strong>，尤其适用于需要等待异步事件或框架响应式更新完成的情况。</li>
<li><strong>放弃复杂的全局事件监听，采用简单的点击回调配合状态变量，代码更可控</strong>。</li>
</ul>

<h3 id="什么时候适合用原生-details">什么时候适合用原生 <code>&lt;details&gt;</code>？</h3>

<ul>
<li>项目规模小，不需要复杂动画</li>
<li>你希望完全控制样式和行为</li>
<li>读者群体以技术人群为主，对原生 Web 标准接受度高</li>
</ul>

<h3 id="什么时候该用组件库的手风琴">什么时候该用组件库的手风琴？</h3>

<ul>
<li>需要平滑动画、键盘导航、多选模式</li>
<li>团队协作，希望快速交付</li>
<li>无障碍要求高</li>
</ul>

<hr>

<h2 id="8-结语">8. 结语</h2>

<p>这次为系列页面添加“全部折叠/展开”功能，让我对原生 DOM 操作和 Vue 状态同步有了更深的认识。虽然只是一个小小的功能，但背后的原理和调试过程值得记录。希望这篇文章能帮到同样在使用原生 <code>&lt;details&gt;</code> 时遇到类似问题的你。</p>

<hr>

<blockquote>
<p><strong>后记（方案演进）</strong>：本文记录的 <code>&lt;details&gt;</code> + DOM 查询方案是系列页的早期实现。后来该页面已重构为<strong>纯状态驱动</strong>方案：<code>&lt;button&gt;</code> + <code>v-show</code> + 一个 <code>expandedSeries</code> 数组（展开状态存于响应式状态，并持久化到 <code>localStorage</code>），不再需要 <code>document.querySelectorAll(&quot;details&quot;)</code>，也不需要 <code>setTimeout</code> 延迟同步。展开状态只由 Vue 维护，手动点击与“全部展开/折叠”走同一套更新逻辑，SSR 水合与按钮图标天然一致——本文踩到的“DOM 事件同步脆弱”问题也随之消失。重构后的实现见博客仓库的 <code>app/pages/series.vue</code>。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[本文记录了在博客系列页面中使用原生 `<details>` 实现折叠列表时，添加“全部折叠/展开”按钮遇到的“点两次”问题，通过放弃 `toggle` 事件监听、直接同步状态变量和使用 `setTimeout` 等待 DOM 更新，最终实现稳定可控的全局控制功能。包含完整代码和原理分析。]]></description>
      <category><![CDATA[HTML]]></category>
      <category><![CDATA[CSS]]></category>
      <category><![CDATA[JavaScript]]></category>
      <category><![CDATA[Nuxt]]></category>
      
    </item>

    <item>
      <title><![CDATA[为评论区添加内容过滤与安全防护]]></title>
      <link>https://moongate.top/docs/nuxt-comment-security</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-comment-security</guid>
      <pubDate>Mon, 23 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="1-背景与需求">1. 背景与需求</h2>

<p>在开放的评论区中，可能面临以下风险：</p>

<ul>
<li><strong>敏感词</strong>：用户可能发布违规内容，影响社区氛围。</li>
<li><strong>恶意灌水</strong>：大量重复评论或垃圾广告。</li>
<li><strong>跨文档污染</strong>：通过伪造 <code>permalink</code> 将评论插入到不存在的文档或他人文章。</li>
<li><strong>XSS 攻击</strong>：通过 Markdown 注入恶意脚本（已在前文中通过安全渲染解决）。</li>
</ul>

<p>为了维护评论区秩序，我们需要增加以下防护：</p>

<ul>
<li><strong>前端实时敏感词提示</strong>：提升用户体验，减少无效提交。</li>
<li><strong>后端严格过滤</strong>：作为最后防线，确保入库内容安全。</li>
<li><strong>文档归属验证</strong>：确保评论只属于当前文档。</li>
<li><strong>防重复提交与限流</strong>：防止恶意刷屏。</li>
</ul>

<h2 id="2-整体架构">2. 整体架构</h2>

<p>过滤功能涉及前后端协作：</p>

<pre><code class="language-text">用户输入 → 前端实时检测（可选） → 提交 → 后端校验（敏感词、长度、文档归属） → 入库 → 返回结果
</code></pre>

<p>本文将分模块实现。</p>

<h2 id="3-敏感词过滤">3. 敏感词过滤</h2>

<h3 id="3-1-词库设计">3.1 词库设计</h3>

<p>敏感词库应仅包含<strong>底线词汇</strong>，避免过度拦截技术术语。同时引入<strong>技术白名单</strong>，允许某些专业词汇（如“暴力破解”）正常使用。</p>

<pre><code class="language-ts">// utils/commentValidator.ts

// 敏感词列表（仅底线词汇）
const blockedKeywords = [
  &quot;广告&quot;,
  &quot;垃圾&quot;,
  &quot;诈骗&quot;,
  &quot;赌博&quot;,
  &quot;色情&quot;,
  &quot;暴力&quot;,
  &quot;fuck&quot;,
  &quot;shit&quot;,
  &quot;damn&quot;,
]

// 技术白名单（豁免词汇，需与敏感词冲突时使用）
const technicalWhitelist = [
  &quot;暴力破解&quot;,
  &quot;暴力枚举&quot;,
  &quot;攻击向量&quot;,
  &quot;死锁&quot;,
  &quot;死循环&quot;,
  &quot;垃圾回收&quot;,
  &quot;垃圾收集&quot;,
]
</code></pre>

<h3 id="3-2-验证函数实现">3.2 验证函数实现</h3>

<p>为避免将白名单词汇误判为敏感词，采用<strong>先移除白名单内容，再检测敏感词</strong>的策略，而非简单的子串匹配。</p>

<pre><code class="language-ts">// utils/commentValidator.ts

// 构建敏感词正则（大小写不敏感，自动转义）
const sensitiveRegex = new RegExp(
  blockedKeywords
    .map((word) =&gt; word.replace(/[.*+?^${}()|[\]\\]/g, &quot;\\$&amp;&quot;))
    .join(&quot;|&quot;),
  &quot;i&quot;,
)

export interface ValidationResult {
  valid: boolean
  message?: string
  foundWords?: string[]
}

/**
 * 验证评论内容
 * @param content 用户输入的文本
 * @param maxLength 最大长度，默认 5000（考虑代码块）
 */
export function validateComment(
  content: string,
  maxLength: number = 5000,
): ValidationResult {
  // 1. 空内容检查
  if (!content?.trim()) {
    return { valid: false, message: &quot;评论内容不能为空&quot; }
  }

  // 2. 长度限制
  if (content.length &gt; maxLength) {
    return { valid: false, message: `评论内容不能超过 ${maxLength} 个字符` }
  }

  // 3. 移除白名单词汇，避免误判
  let text = content
  for (const word of technicalWhitelist) {
    text = text.replace(new RegExp(word, &quot;gi&quot;), &quot;&quot;)
  }

  // 4. 敏感词检测
  const matches = text.match(sensitiveRegex)
  if (matches) {
    const found = [...new Set(matches)]
    return {
      valid: false,
      message: `包含敏感词: ${found.join(&quot;, &quot;)}`,
      foundWords: found,
    }
  }

  return { valid: true }
}
</code></pre>

<p><strong>说明</strong>：</p>

<ul>
<li>通过先剔除白名单词汇，确保“垃圾回收”不会被误判为“垃圾”。</li>
<li>长度默认 5000 字符，足以容纳中等长度的代码块和技术讨论（一段 30 行的代码约 2400 字符）。</li>
<li>正则表达式一次匹配所有敏感词，性能优于循环 <code>includes</code>。</li>
</ul>

<h3 id="3-3-前端实时验证与提交按钮联动">3.3 前端实时验证与提交按钮联动</h3>

<h4 id="3-3-1-修改输入预览组件">3.3.1 修改输入预览组件</h4>

<p>在 <code>components/docs/CommentInputPreview.vue</code> 中增加实时验证和字符计数。组件的基础模板（预览/输入双栏布局）与<a href="./nuxt-multi-level-replies">《多级引用评论区》§5.3</a>完全一致，此处仅展示<strong>新增的验证逻辑与 UI 差异</strong>：</p>

<pre><code class="language-vue">&lt;!-- 在原有模板的输入栏中新增（位于 UTextarea 之后）： --&gt;

&lt;!-- 新增1：验证错误提示 --&gt;
&lt;div v-if=&quot;validationError&quot; class=&quot;mt-2 text-xs font-mono text-ui-error&quot;&gt;
  // {{ validationError }}
&lt;/div&gt;

&lt;!-- 新增2：字符计数 --&gt;
&lt;div class=&quot;text-xs text-ui-text-muted text-right mt-1&quot;&gt;
  {{ localValue.length }}/{{ maxLength }}
&lt;/div&gt;
</code></pre>

<pre><code class="language-ts">// script 中新增的部分：
import { validateComment } from &quot;~/utils/commentValidator&quot;;

// props 新增 maxLength（默认 5000）
maxLength: { type: Number, default: 5000 }

// 新增状态
const validationError = ref('');

// 新增验证函数
const validate = (value: string) =&gt; {
  const result = validateComment(value, props.maxLength);
  validationError.value = result.valid ? '' : result.message;
  return result.valid;
};

// 修改 handleInput：在原有防抖逻辑基础上增加实时验证
const handleInput = (value: string) =&gt; {
  localValue.value = value;
  validate(value);          // ★ 新增：实时验证，仅用于显示错误提示
  debouncedEmit(value);     // 原有防抖逻辑不变
};

// 新增：监听外部 modelValue 变化时重新验证
watch(() =&gt; props.modelValue, (newVal) =&gt; {
  localValue.value = newVal;
  validate(newVal);         // ★ 新增
});

// 新增：挂载时执行初始验证
onMounted(() =&gt; validate(localValue.value));
</code></pre>

<h4 id="3-3-2-在-store-中管理验证状态">3.3.2 在 Store 中管理验证状态</h4>

<p>为了让提交按钮能够响应验证结果，需要在评论 store 中增加计算属性：</p>

<pre><code class="language-ts">// stores/comment.ts
import { validateComment } from &quot;~/utils/commentValidator&quot;

export const useCommentStore = defineStore(&quot;comment&quot;, () =&gt; {
  const comment = ref(&quot;&quot;)
  const error = ref(&quot;&quot;) // 后端返回的错误信息，用于在界面上展示（§3.3.3）
  // ... 其他状态

  const isCommentValid = computed(() =&gt; {
    const { valid } = validateComment(comment.value, 5000)
    return valid
  })

  return {
    comment,
    error,
    isCommentValid,
    // ... 其他
  }
})
</code></pre>

<blockquote>
<p>💡 还需在 <code>submitComment</code> / <code>submitReply</code> 中写入或清空 <code>error</code>（完整方法见本系列第 2 篇 <a href="./nuxt-multi-level-replies">《多级引用评论区》§4</a>），例如：<code>error.value = response.success ? &quot;&quot; : (response.message || &quot;提交失败，请稍后再试&quot;)</code>。</p>
</blockquote>

<h4 id="3-3-3-修改评论区容器组件">3.3.3 修改评论区容器组件</h4>

<p><code>CommentSection.vue</code> 的完整模板见本系列第 2 篇 <a href="./nuxt-multi-level-replies">《多级引用评论区》§5.1</a>，这里只需改动两处：</p>

<pre><code class="language-vue">&lt;!-- 改动1：提交按钮的 :disabled 追加验证条件（其余条件与 §5.1 相同） --&gt;
:disabled=&quot;
  !commentStore.comment.trim() ||
  commentStore.submitting ||
  !commentStore.isCommentValid
&quot;

&lt;!-- 改动2：在评论区底部展示后端错误 --&gt;
&lt;div v-if=&quot;commentStore.error&quot; class=&quot;text-ui-error text-sm mt-2&quot;&gt;
  {{ commentStore.error }}
&lt;/div&gt;
</code></pre>

<h3 id="3-4-后端严格验证">3.4 后端严格验证</h3>

<p>在评论和回复的 API 中，必须再次调用 <code>validateComment</code>，确保任何绕过前端的请求都被拦截。所有 API 统一返回对象格式（不使用 <code>throw createError</code>），以便前端统一处理。</p>

<h4 id="修改-server-api-comment-post-ts">修改 <code>server/api/comment/post.ts</code></h4>

<pre><code class="language-ts">import { eq } from &quot;drizzle-orm&quot;
import { useDB } from &quot;~~/server/db&quot;
import { comments, users } from &quot;~~/server/db/schema&quot;
import { validateComment } from &quot;~/../utils/commentValidator&quot;

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event)
  const session = await getUserSession(event)

  // 1. 验证 session
  if (!session.user?.id) {
    return { success: false, status: 401, message: &quot;请先登录&quot; }
  }

  // 2. 查询用户是否存在
  const db = useDB()
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
  })
  if (!user) {
    await clearUserSession(event)
    return { success: false, status: 401, message: &quot;用户不存在&quot; }
  }

  // 3. 验证评论内容
  const content = body.content?.trim()
  const { valid, message } = validateComment(content, 5000)
  if (!valid) {
    return { success: false, status: 400, message: message || &quot;评论内容无效&quot; }
  }

  // 4. 验证 permalink 非空
  if (!body.permalink) {
    return { success: false, status: 400, message: &quot;永久链接不能为空&quot; }
  }

  // 5. 验证文档是否存在（假设使用 Nuxt Content，具体实现需根据项目调整）
  // 注意：这里使用了 `#content/server` 虚拟模块，实际项目中可能需要替换为其他方式。
  // 若无法验证，应返回错误而不是放行。
  try {
    const { queryCollection } = await import(&quot;#content/server&quot;)
    const doc = await queryCollection(&quot;docs&quot;)
      .where(&quot;permalink&quot;, &quot;=&quot;, body.permalink)
      .first()
    if (!doc) {
      return { success: false, status: 404, message: &quot;文档不存在&quot; }
    }
  } catch (err) {
    console.error(&quot;文档存在性验证失败，请检查 Nuxt Content 服务端配置&quot;, err)
    return {
      success: false,
      status: 500,
      message: &quot;服务器配置错误，无法验证文档&quot;,
    }
  }

  // 6. 保存评论到数据库
  try {
    const [comment] = await db
      .insert(comments)
      .values({
        user_id: user.id,
        content: body.content.trim(),
        permalink: body.permalink,
      })
      .returning()

    return {
      success: true,
      status: 201,
      message: &quot;评论存储成功&quot;,
      data: { ...comment },
    }
  } catch (error) {
    console.error(&quot;评论存储失败&quot;, error)
    return { success: false, status: 500, message: &quot;评论存储失败&quot; }
  }
})
</code></pre>

<p>同样修改 <code>server/api/reply/post.ts</code>（完整代码见第 4 节）。</p>

<h2 id="4-文档归属验证">4. 文档归属验证</h2>

<h3 id="4-1-评论-api-验证文档存在">4.1 评论 API 验证文档存在</h3>

<p>已在评论 API 中实现（见 3.4 节）。注意：文档存在性验证依赖于 Nuxt Content 的服务端能力，若不可用，建议在数据库中维护 <code>documents</code> 表，并在评论表中关联文档 ID。</p>

<h3 id="4-2-回复-api-完整代码-含归属验证">4.2 回复 API 完整代码（含归属验证）</h3>

<p>由于回复表没有 <code>permalink</code> 字段，需要通过目标评论的 <code>permalink</code> 来验证。下面给出完整的 <code>reply/post.ts</code> 实现，包含用户认证、参数校验、敏感词过滤、归属验证和限流（可选）。</p>

<pre><code class="language-ts">import { eq, sql } from &quot;drizzle-orm&quot;
import { useDB } from &quot;~~/server/db&quot;
import { replies, users, comments } from &quot;~~/server/db/schema&quot;
import { validateComment } from &quot;~/../utils/commentValidator&quot;

// 注意：内存限流仅用于演示，生产环境请替换为 Redis 或数据库
const rateLimit = new Map()

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event)
  const session = await getUserSession(event)

  // 1. 参数校验
  if (
    !body.target_id ||
    ![&quot;comment&quot;, &quot;reply&quot;].includes(body.target_type) ||
    !body.content?.trim()
  ) {
    return { success: false, status: 400, message: &quot;参数错误&quot; }
  }
  if (!body.permalink) {
    return { success: false, status: 400, message: &quot;缺少 permalink 参数&quot; }
  }
  // 确保 target_id 为数字
  const targetId = Number(body.target_id)
  if (isNaN(targetId)) {
    return { success: false, status: 400, message: &quot;target_id 必须为数字&quot; }
  }

  // 2. 验证 session
  if (!session.user?.id) {
    return { success: false, status: 401, message: &quot;请先登录&quot; }
  }

  const db = useDB()

  // 3. 验证用户存在
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
  })
  if (!user) {
    await clearUserSession(event)
    return { success: false, status: 401, message: &quot;用户不存在&quot; }
  }

  // 4. 验证回复内容
  const content = body.content?.trim()
  const { valid, message } = validateComment(content, 5000)
  if (!valid) {
    return {
      success: false,
      status: 400,
      message: message || &quot;回复包含敏感词&quot;,
    }
  }

  // 5. 验证目标存在并检查是否属于当前文档
  let targetPermalink = &quot;&quot;
  if (body.target_type === &quot;comment&quot;) {
    const comment = await db.query.comments.findFirst({
      where: eq(comments.id, targetId),
    })
    if (!comment) {
      return { success: false, status: 404, message: &quot;评论不存在&quot; }
    }
    targetPermalink = comment.permalink
  } else {
    // 目标是回复，需要向上追溯找到根评论的 permalink
    // 使用递归 CTE 查询（PostgreSQL）
    const result = await db.execute(sql`
      WITH RECURSIVE reply_chain AS (
        SELECT id, target_id, target_type
        FROM replies
        WHERE id = ${targetId}
        UNION ALL
        SELECT r.id, r.target_id, r.target_type
        FROM replies r
        JOIN reply_chain rc ON rc.target_id = r.id AND rc.target_type = 'reply'
      )
      SELECT c.permalink
      FROM reply_chain rc
      JOIN comments c ON c.id = rc.target_id AND rc.target_type = 'comment'
      LIMIT 1
    `)
    if (!result.rows.length) {
      return { success: false, status: 404, message: &quot;目标评论或回复不存在&quot; }
    }
    targetPermalink = result.rows[0].permalink
  }

  if (targetPermalink !== body.permalink) {
    return { success: false, status: 400, message: &quot;目标不属于当前文档&quot; }
  }

  // 6. 可选：防重复提交限流（同一用户对同一文档 1 分钟内只能回复一次）
  // 内存限流仅用于演示，生产环境实现见 §5.2
  const rateKey = `${user.id}:${body.permalink}`
  const last = rateLimit.get(rateKey)
  if (last &amp;&amp; Date.now() - last &lt; 60000) {
    return { success: false, status: 429, message: &quot;操作过于频繁，请稍后再试&quot; }
  }
  rateLimit.set(rateKey, Date.now())
  setTimeout(() =&gt; rateLimit.delete(rateKey), 60000) // 自动清理过期条目

  // 7. 保存回复
  try {
    const [newReply] = await db
      .insert(replies)
      .values({
        user_id: user.id,
        target_id: targetId,
        target_type: body.target_type,
        content: content,
      })
      .returning()

    return {
      success: true,
      status: 201,
      message: &quot;回复成功&quot;,
      data: newReply,
    }
  } catch (error) {
    console.error(error)
    return { success: false, status: 500, message: &quot;服务器内部错误&quot; }
  }
})
</code></pre>

<p><strong>说明</strong>：</p>

<ul>
<li>递归 CTE 查询支持多级引用（回复的回复），并获取根评论的 <code>permalink</code>。</li>
<li>若不需要多级引用，可以限制 <code>target_type</code> 只能为 <code>'comment'</code>，简化归属验证。</li>
<li>限流使用内存 Map，仅用于演示；生产环境实现见 §5.2。</li>
<li>增加了 <code>target_id</code> 类型校验，确保为有效数字。</li>
</ul>

<h2 id="5-防重复提交与限流">5. 防重复提交与限流</h2>

<h3 id="5-1-前端禁用按钮">5.1 前端禁用按钮</h3>

<p>已在 <code>CommentSection.vue</code> 中实现，通过 <code>submitting</code> 状态禁用提交按钮。</p>

<h3 id="5-2-后端限流">5.2 后端限流</h3>

<p>已在回复 API 中添加简单内存限流示例（每分钟 1 次）。<strong>生产环境请务必替换为 Redis 或数据库</strong>，避免内存丢失或跨实例不同步。</p>

<h2 id="6-多语言翻译">6. 多语言翻译</h2>

<p>新增的文案需要添加到多语言文件中。以下提供中、英、日三语示例，可根据实际路径调整。</p>

<h3 id="i18n-locales-zh-cn-json">/i18n/locales/zh_cn.json</h3>

<pre><code class="language-json">{
  &quot;comment&quot;: {
    &quot;input&quot;: {
      &quot;preview&quot;: &quot;预览&quot;,
      &quot;input&quot;: &quot;输入&quot;,
      &quot;placeholder&quot;: &quot;支持 Markdown，代码块请用 ``` 包裹...&quot;
    }
  }
}
</code></pre>

<h3 id="i18n-locales-en-json">/i18n/locales/en.json</h3>

<pre><code class="language-json">{
  &quot;comment&quot;: {
    &quot;input&quot;: {
      &quot;preview&quot;: &quot;Preview&quot;,
      &quot;input&quot;: &quot;Input&quot;,
      &quot;placeholder&quot;: &quot;Markdown supported, use ``` for code blocks...&quot;
    }
  }
}
</code></pre>

<h3 id="i18n-locales-ja-json">/i18n/locales/ja.json</h3>

<pre><code class="language-json">{
  &quot;comment&quot;: {
    &quot;input&quot;: {
      &quot;preview&quot;: &quot;プレビュー&quot;,
      &quot;input&quot;: &quot;入力&quot;,
      &quot;placeholder&quot;: &quot;Markdown 対応、コードブロックは ``` で囲んでください...&quot;
    }
  }
}
</code></pre>

<h2 id="7-整合与测试">7. 整合与测试</h2>

<h3 id="7-1-文件清单">7.1 文件清单</h3>

<ul>
<li><code>utils/commentValidator.ts</code> – 敏感词验证逻辑（含白名单剔除）</li>
<li><code>components/docs/CommentInputPreview.vue</code> – 前端实时验证、字符计数</li>
<li><code>stores/comment.ts</code> – 添加 <code>isCommentValid</code> 计算属性和 <code>error</code> 状态</li>
<li><code>server/api/comment/post.ts</code> – 后端验证 + 文档归属</li>
<li><code>server/api/reply/post.ts</code> – 后端验证 + 归属验证 + 限流（含类型校验）</li>
</ul>

<h3 id="7-2-测试用例">7.2 测试用例</h3>

<ul>
<li><strong>空内容</strong> → 提示“评论内容不能为空”</li>
<li><strong>超长内容</strong>（&gt;5000 字符）→ 提示“不能超过 5000 个字符”</li>
<li><strong>包含敏感词</strong>（如“垃圾”）→ 前端提示，提交按钮禁用；若绕过前端，后端返回错误</li>
<li><strong>包含白名单词</strong>（如“暴力破解”）→ 正常提交</li>
<li><strong>伪造 <code>permalink</code></strong> → 后端返回“文档不存在”</li>
<li><strong>快速多次提交</strong> → 触发限流提示（若实现）</li>
<li><strong>回复多级引用</strong> → 递归查询正确找到根评论归属</li>
</ul>

<h2 id="8-总结">8. 总结</h2>

<p>通过添加内容过滤、归属验证和限流机制，评论区的安全性大大提升，能够抵御常见的恶意行为。这些功能与已有的 Markdown 安全渲染、用户认证共同构成了一个健壮的评论系统。</p>

<p>现在，评论区已经可以放心地开放给所有读者了。</p>
]]></content:encoded>
      <description><![CDATA[为 Nuxt 评论区增加敏感词过滤、文档归属验证、防重复提交与限流，构建多层安全防护体系。包含前端实时验证、后端严格校验、递归 CTE 归属验证及生产环境建议。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[Security]]></category>
      <dc:relation><![CDATA[series:comment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[手写一个更适合 Nuxt 的 useRouteQuery：简化 URL 状态同步]]></title>
      <link>https://moongate.top/docs/nuxt-use-route-query-composables</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-use-route-query-composables</guid>
      <pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>在生产项目中，我经历过手写 70 行重复的 <code>watch</code> 与 <code>pushQuery</code>，也踩过官方 <code>@vueuse/router</code> 的 SSR 坑。最终我封装了一套开箱即用的 <code>useRouteQueryString</code>、<code>useRouteQueryNumber</code>、<code>useRouteQueryArray</code>，将代码量从 70 行压缩到 7 行，且完全可控、SSR 安全。本文将分享这套封装的设计思路与完整代码。</p>
</blockquote>

<h2 id="一-背景-手写方案的痛点">一、背景：手写方案的痛点</h2>

<p>在 Nuxt 中实现 URL 与状态双向同步，最常见的做法是手写整套闭环：为每个状态定义 ref、写一段 <code>watch(route.query)</code> 把 URL 变化同步回 ref、再写一个 <code>pushQuery()</code> 把 ref 变化写回 URL（完整代码与逐行讲解见系列第 1 篇<a href="./nuxt-url-state-guide">《Nuxt 中 URL 与状态双向绑定指南》</a> §2.2，此处不再重复）。它的形态大致是：</p>

<pre><code class="language-ts">// 7 个状态：searchInput / searchOption / page / size / viewMode / level / tags
// 1 段 watch(route.query) → 同步到内部 ref（含 parseTagsFromQuery）
// 1 个 pushQuery() 函数体 + 若干 watch(refs) → 写回 URL
</code></pre>

<p>重复 7 个状态，代码量庞大，且每个新页面都要重写一遍。这种代码不仅笨重，还容易漏掉某个 <code>watch</code>，导致 URL 与状态不同步。</p>

<h2 id="二-官方-useroutequery-的隐患">二、官方 <code>useRouteQuery</code> 的隐患</h2>

<p><code>@vueuse/router</code> 提供了 <code>useRouteQuery</code>，看似简洁，但我在生产环境踩过坑：它内部使用全局 <code>WeakMap</code> + <code>nextTick</code> 批量更新，SSR 下可能跨请求污染，最终导致 <code>Invalid value used as weak map key</code> 的 500 错误。完整排查经过见系列第 1 篇<a href="./nuxt-url-state-guide">《Nuxt 中 URL 与状态双向绑定指南》</a> §三，此处不再重复。结论是：我放弃了第三方库，决定自己封装一个稳定、可控的版本。</p>

<h2 id="三-封装设计-按类型拆分-各司其职">三、封装设计：按类型拆分，各司其职</h2>

<p>我将 URL 查询参数按常见类型拆分为三个专用函数，每个函数只做一件事，语义清晰。</p>

<h3 id="3-1-基础函数-useroutequeryraw">3.1 基础函数 <code>useRouteQueryRaw</code></h3>

<p>不对外暴露，仅用于内部读写原始值，<strong>使用 <code>replace</code> 避免产生多余历史记录</strong>：</p>

<pre><code class="language-ts">function useRouteQueryRaw(name: string) {
  const route = useRoute()
  const router = useRouter()
  const value = ref(route.query[name])

  // 监听路由变化，同步到内部 ref
  watch(
    () =&gt; route.query[name],
    (newVal) =&gt; {
      value.value = newVal
    },
  )

  // 监听内部 ref 变化，同步到 URL
  watch(value, (newVal) =&gt; {
    const query = { ...route.query }
    if (newVal !== undefined &amp;&amp; newVal !== null &amp;&amp; newVal !== &quot;&quot;) {
      query[name] = newVal
    } else {
      delete query[name]
    }
    router.replace({ query })
  })

  return value
}
</code></pre>

<h4 id="为什么用-replace-而不是-push">为什么用 <code>replace</code> 而不是 <code>push</code>？</h4>

<p>如果使用 <code>push</code>，每次筛选条件变化都会在浏览器历史中产生一条新记录，用户点击后退按钮时需要多次后退才能离开当前页面。<code>replace</code> 只替换当前历史记录，用户体验更符合直觉。</p>

<h3 id="3-2-字符串类型-useroutequerystring">3.2 字符串类型 <code>useRouteQueryString</code></h3>

<pre><code class="language-ts">export function useRouteQueryString(
  name: string,
  options?: { defaultValue?: string },
) {
  const raw = useRouteQueryRaw(name)
  const defaultValue = options?.defaultValue ?? &quot;&quot;

  return computed({
    get: () =&gt; (raw.value?.toString() ?? defaultValue) as string,
    set: (v: string) =&gt; {
      raw.value = v === defaultValue ? undefined : v
    },
  }) as Ref&lt;string&gt;
}
</code></pre>

<h3 id="3-3-数字类型-useroutequerynumber">3.3 数字类型 <code>useRouteQueryNumber</code></h3>

<pre><code class="language-ts">export function useRouteQueryNumber(
  name: string,
  options?: { defaultValue?: number },
) {
  const raw = useRouteQueryRaw(name)
  const defaultValue = options?.defaultValue ?? 0

  return computed({
    get: () =&gt; {
      const val = raw.value
      if (val === undefined) return defaultValue
      const num = Number(val)
      return isNaN(num) ? defaultValue : num
    },
    set: (v: number) =&gt; {
      raw.value = v === defaultValue ? undefined : v.toString()
    },
  }) as Ref&lt;number&gt;
}
</code></pre>

<h3 id="3-4-数组类型-从逗号分隔到多参数格式">3.4 数组类型：从逗号分隔到多参数格式</h3>

<p>在早期版本中，<code>useRouteQueryArray</code> 使用逗号分隔格式：</p>

<pre><code class="language-ts">// 旧版：逗号分隔
set: (v: string[]) =&gt; {
  raw.value = v.length ? v.join(&quot;,&quot;) : undefined
}
// URL：?tag=go,vue
</code></pre>

<p>当项目引入 Go Gin 后端后，问题出现了：</p>

<pre><code class="language-go">// Go 后端期望：?tag=go&amp;tag=vue
tags := c.QueryArray(&quot;tag&quot;)  // 期望 [&quot;go&quot;, &quot;vue&quot;]

// 但前端发送的是：?tag=go,vue
tags := c.QueryArray(&quot;tag&quot;)  // 得到 [&quot;go,vue&quot;] ❌
</code></pre>

<p>Gin 的 <code>QueryArray</code> 原生支持多参数格式（<code>?tag=go&amp;tag=vue</code>），但不认识逗号分隔。如果继续用逗号分隔，就需要在 Go 后端手动 <code>strings.Split</code> 解析。</p>

<p>与其在每个后端接口都写一遍解析逻辑，不如统一改成多参数格式：</p>

<pre><code class="language-ts">// 新版：多参数格式
watch(
  value,
  (newVal) =&gt; {
    const query = { ...route.query }
    if (newVal.length === 0) {
      delete query[name]
    } else {
      query[name] = newVal // Vue Router 自动展开成 ?tag=go&amp;tag=vue
    }
    router.replace({ query })
  },
  { deep: true },
)
</code></pre>

<h4 id="一次修改-前后端格式对齐">一次修改，前后端格式对齐</h4>

<pre><code class="language-text">前端写入：tags.value = ['go', 'vue']
URL 变成：?tag=go&amp;tag=vue
Go 读取：c.QueryArray(&quot;tag&quot;) → [&quot;go&quot;, &quot;vue&quot;] ✅
</code></pre>

<h4 id="完整实现">完整实现</h4>

<pre><code class="language-ts">/**
 * 字符串数组类型查询参数
 * 使用多参数格式：?tag=go&amp;tag=vue
 * 与 Gin 的 c.QueryArray(&quot;tag&quot;) 天然兼容，无需后端额外解析
 */
export function useRouteQueryArray(name: string) {
  const route = useRoute()
  const router = useRouter()

  const getValue = (): string[] =&gt; {
    const val = route.query[name]
    if (!val) return []
    return Array.isArray(val) ? val : [val]
  }

  const value = ref(getValue())

  watch(
    () =&gt; route.query[name],
    () =&gt; {
      const newVal = getValue()
      if (JSON.stringify(value.value) !== JSON.stringify(newVal)) {
        value.value = newVal
      }
    },
  )

  watch(
    value,
    (newVal) =&gt; {
      const query = { ...route.query }
      if (newVal.length === 0) {
        delete query[name]
      } else {
        query[name] = newVal
      }
      router.replace({ query })
    },
    { deep: true },
  )

  return value
}
</code></pre>

<table>
<thead>
<tr>
<th>格式</th>
<th>URL 示例</th>
<th>Gin 解析</th>
<th>标准程度</th>
</tr>
</thead>

<tbody>
<tr>
<td>逗号分隔（旧版）</td>
<td><code>?tag=go,vue</code></td>
<td>需手动 <code>strings.Split</code></td>
<td>❌ 非标准</td>
</tr>

<tr>
<td>多参数（新版）</td>
<td><code>?tag=go&amp;tag=vue</code></td>
<td><code>c.QueryArray(&quot;tag&quot;)</code> 原生支持</td>
<td>✅ HTTP 标准</td>
</tr>
</tbody>
</table>

<h2 id="四-使用示例-从-70-行到-7-行">四、使用示例：从 70 行到 7 行</h2>

<h3 id="4-1-定义状态">4.1 定义状态</h3>

<pre><code class="language-ts">const searchInput = useRouteQueryString(&quot;search&quot;, { defaultValue: &quot;&quot; })
const searchOption = useRouteQueryNumber(&quot;option&quot;, { defaultValue: 1 })
const page = useRouteQueryNumber(&quot;page&quot;, { defaultValue: 1 })
const size = useRouteQueryNumber(&quot;size&quot;, { defaultValue: 10 })
const viewMode = useRouteQueryNumber(&quot;viewMode&quot;, { defaultValue: 1 })
const level = useRouteQueryString(&quot;level&quot;, { defaultValue: &quot;&quot; })
const tags = useRouteQueryArray(&quot;tag&quot;)
</code></pre>

<h3 id="4-2-在模板中使用">4.2 在模板中使用</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;UInput v-model=&quot;searchInput&quot; placeholder=&quot;搜索&quot; /&gt;
  &lt;!-- 其他筛选组件直接使用对应的状态变量 --&gt;
&lt;/template&gt;
</code></pre>

<h3 id="4-3-处理搜索防抖">4.3 处理搜索防抖</h3>

<p>由于直接修改 <code>searchInput</code> 会立即更新 URL，如果你希望实现&rdquo;输入停止后才更新&rdquo;的效果，可以引入一个防抖中间变量：</p>

<pre><code class="language-ts">// 实际搜索词（与 URL 同步）
const searchInput = useRouteQueryString(&quot;search&quot;, { defaultValue: &quot;&quot; })
const page = useRouteQueryNumber(&quot;page&quot;, { defaultValue: 1 })

// 防抖中间变量
const searchInputDebounced = ref(searchInput.value)

// 防抖写入 URL
watchDebounced(
  searchInputDebounced,
  (val) =&gt; {
    searchInput.value = val
    page.value = 1
  },
  { debounce: 500 },
)

// URL 变化时反向同步到防抖变量（后退/前进时保持输入框一致）
watch(searchInput, (val) =&gt; {
  searchInputDebounced.value = val
})
</code></pre>

<p>模板中绑定 <code>searchInputDebounced</code> 而不是 <code>searchInput</code>，实现输入防抖同时保持 URL 双向同步。</p>

<h2 id="五-方案对比">五、方案对比</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>手写方案（70行/页面）</th>
<th>官方 <code>useRouteQuery</code></th>
<th>本封装</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>SSR 安全</strong></td>
<td>✅</td>
<td>⚠️ 有隐患（<code>WeakMap</code> 跨请求）</td>
<td>✅</td>
</tr>

<tr>
<td><strong>数组支持</strong></td>
<td>需手动解析</td>
<td>需 <code>transform</code></td>
<td>✅ 内置</td>
</tr>

<tr>
<td><strong>数组格式</strong></td>
<td>任意</td>
<td>任意</td>
<td><strong>多参数格式</strong>（标准）</td>
</tr>

<tr>
<td><strong>使用便捷</strong></td>
<td>❌ 繁琐</td>
<td>中等</td>
<td>函数名即类型</td>
</tr>

<tr>
<td><strong>代码量</strong></td>
<td>~70行/页面</td>
<td>~15行</td>
<td>~7行</td>
</tr>

<tr>
<td><strong>历史记录</strong></td>
<td>可配置</td>
<td><code>push</code></td>
<td><code>replace</code>（更符合直觉）</td>
</tr>
</tbody>
</table>

<h2 id="六-ssr-安全保证">六、SSR 安全保证</h2>

<p>以下三条针对上文 3.1 的<strong>简化版实现</strong>成立（其中&rdquo;初始状态从 URL 同步读取&rdquo;等更通用的水合原则，与系列第 1 篇<a href="./nuxt-url-state-guide">《Nuxt 中 URL 与状态双向绑定指南》</a> §四一致，此处不展开）：</p>

<ul>
<li><strong>无全局状态</strong>：所有数据存储在组件实例的 <code>ref</code> 中，不会跨请求污染。</li>
<li><strong>直接监听 <code>route.query</code></strong>：保证服务端和客户端初始值一致。</li>
<li><strong>不使用 <code>nextTick</code></strong>：避免在 SSR 中因异步更新导致 DOM 不匹配。</li>
</ul>

<blockquote>
<p><strong>实现演进（重要）</strong>：上文 3.1 的简化版每个参数独立 <code>watch</code>，确实&rdquo;无全局状态&rdquo;。但当页面需要 <strong><code>resetFilters</code> 一次性重置多个参数</strong> 时，多个独立 watch 会在同一 tick 各自基于旧的 <code>route.query</code> 写回，导致前面的修改被后面的覆盖（重置 6 个参数最终只生效最后一个）。</p>

<p>为解决覆盖问题，实际项目的实现演进出<strong>注册表机制</strong>：所有 <code>useRouteQuery*</code> 参数先向同一个注册表登记，写回 URL 前从注册表读取所有参数的最新值，一次构建完整 query。此时&rdquo;无全局状态&rdquo;的前提已不成立——注册表本身就是共享状态，它的存放位置直接决定 SSR 安全性：</p>

<ul>
<li>❌ <strong>模块级 <code>Set</code></strong>：跨请求累积（<code>onUnmounted</code> 在服务端不触发），会造成服务端内存泄漏——正是 <a href="./nuxt-ssr-memory-leak-troubleshooting">《Nuxt SSR 内存泄漏排查实录》</a> 记录的真实案例。</li>
<li>✅ <strong>以 <code>nuxtApp</code> 为 key 的 <code>WeakMap</code></strong>：服务端每个请求有独立注册表（请求结束随 nuxtApp 被 GC），客户端全局唯一注册表（组件卸载时由 <code>onUnmounted</code> 清理）。这才是&rdquo;SSR 安全&rdquo;的完整形态。</li>
</ul>
</blockquote>

<h2 id="七-完整代码">七、完整代码</h2>

<pre><code class="language-ts">// composables/useRouteQuery.ts
import { useRoute, useRouter } from &quot;vue-router&quot;
import type { Ref } from &quot;vue&quot;

/**
 * 基础原始查询参数读写（不暴露给外部，仅内部使用）
 * 负责核心的 URL 同步逻辑，使用 replace 避免产生多余历史记录
 */
function useRouteQueryRaw(name: string) {
  const route = useRoute()
  const router = useRouter()
  const value = ref(route.query[name])

  watch(
    () =&gt; route.query[name],
    (newVal) =&gt; {
      value.value = newVal
    },
  )

  watch(value, (newVal) =&gt; {
    const query = { ...route.query }
    if (newVal !== undefined &amp;&amp; newVal !== null &amp;&amp; newVal !== &quot;&quot;) {
      query[name] = newVal
    } else {
      delete query[name]
    }
    router.replace({ query })
  })

  return value
}

/**
 * 字符串类型查询参数
 */
export function useRouteQueryString(
  name: string,
  options?: { defaultValue?: string },
) {
  const raw = useRouteQueryRaw(name)
  const defaultValue = options?.defaultValue ?? &quot;&quot;
  return computed({
    get: () =&gt; (raw.value?.toString() ?? defaultValue) as string,
    set: (v: string) =&gt; {
      raw.value = v === defaultValue ? undefined : v
    },
  }) as Ref&lt;string&gt;
}

/**
 * 数字类型查询参数
 */
export function useRouteQueryNumber(
  name: string,
  options?: { defaultValue?: number },
) {
  const raw = useRouteQueryRaw(name)
  const defaultValue = options?.defaultValue ?? 0
  return computed({
    get: () =&gt; {
      const val = raw.value
      if (val === undefined) return defaultValue
      const num = Number(val)
      return isNaN(num) ? defaultValue : num
    },
    set: (v: number) =&gt; {
      raw.value = v === defaultValue ? undefined : v.toString()
    },
  }) as Ref&lt;number&gt;
}

/**
 * 字符串数组类型查询参数
 * 使用多参数格式：?tag=go&amp;tag=vue
 * 与 Gin 的 c.QueryArray(&quot;tag&quot;) 天然兼容，无需后端额外解析
 */
export function useRouteQueryArray(name: string) {
  const route = useRoute()
  const router = useRouter()

  const getValue = (): string[] =&gt; {
    const val = route.query[name]
    if (!val) return []
    return Array.isArray(val) ? val : [val]
  }

  const value = ref(getValue())

  watch(
    () =&gt; route.query[name],
    () =&gt; {
      const newVal = getValue()
      if (JSON.stringify(value.value) !== JSON.stringify(newVal)) {
        value.value = newVal
      }
    },
  )

  watch(
    value,
    (newVal) =&gt; {
      const query = { ...route.query }
      if (newVal.length === 0) {
        delete query[name]
      } else {
        query[name] = newVal
      }
      router.replace({ query })
    },
    { deep: true },
  )

  return value
}
</code></pre>

<h2 id="八-总结">八、总结</h2>

<p>这套封装解决了四个核心问题：</p>

<ol>
<li><strong>减少重复代码</strong>：从 70 行重复逻辑缩减到 7 行声明。</li>
<li><strong>保证 SSR 安全</strong>：无全局状态、无 <code>nextTick</code> 依赖，彻底避免水合错误。</li>
<li><strong>更好的历史记录体验</strong>：使用 <code>replace</code> 而非 <code>push</code>，避免后退按钮产生困惑。</li>
<li><strong>标准数组格式</strong>：使用多参数格式（<code>?tag=go&amp;tag=vue</code>），与主流后端框架天然兼容。</li>
</ol>

<p>其中第 4 点是在引入 Go Gin 后端后才意识到的。最初的设计用了逗号分隔，但当后端需要读取 <code>?tag=go&amp;tag=vue</code> 时，才发现格式不兼容。这个教训让我意识到：<strong>前端组件的设计不仅要考虑前端使用体验，也要考虑后端接口的兼容性。</strong> 多参数格式是 HTTP 标准，比自定义的逗号分隔格式更通用。</p>

<p>如果你的项目中也有类似的 URL 状态同步需求，不妨试试这套封装。它已经在我的 Nuxt 项目中稳定运行，希望也能帮到你。</p>
]]></content:encoded>
      <description><![CDATA[封装一套开箱即用的 useRouteQueryString / Number / Array，将 70 行重复的 URL 状态同步代码压缩到 7 行，并彻底解决官方版本的 SSR 隐患。包含完整源码、防抖处理与反向同步示例。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Hydration]]></category>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零到一：构建一个功能完备的文档列表页]]></title>
      <link>https://moongate.top/docs/nuxt-docs-list-page-complete-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-docs-list-page-complete-guide</guid>
      <pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文完整记录了我在 Nuxt 4 中构建一个功能完备的文档列表页的全过程，涵盖 URL 状态同步、SSR 水合问题、移动端适配、标签多选、键盘翻页等 20+ 细节。包含可直接复用的代码片段和踩坑总结，适合正在构建类似页面的开发者。</p>
</blockquote>

<hr>

<h2 id="一-需求与挑战">一、需求与挑战</h2>

<h3 id="最终效果">✅ 最终效果</h3>

<ul>
<li><strong>桌面端</strong>：分页 + 键盘左右键翻页，支持 <strong>Ctrl/⌘ + 点击</strong> 多选标签。</li>
</ul>

<p><img src="../../images/desktop-demo.gif" style="width: 100%; max-width: 800px;" alt="桌面端综合演示" /></p>

<ul>
<li><strong>移动端</strong>：无限滚动 + 下滑刷新，支持 <strong>“多选模式”开关</strong> 进行标签多选（无需键盘）。</li>
</ul>

<p><img src="../../images/mobile-demo.gif" style="width: 100%; max-width: 400px;" alt="移动端综合演示" /></p>

<ul>
<li><strong>所有筛选状态</strong>（搜索词、搜索范围、页码、每页条数、视图模式、等级、标签）均与 URL 同步，页面可分享、可刷新、可后退/前进。</li>
<li><strong>SSR 安全</strong>：无水合错误，服务端和客户端渲染结果完全一致。</li>
</ul>

<h3 id="核心挑战">⚠️ 核心挑战</h3>

<ul>
<li><strong>URL 与状态双向同步</strong>：用户操作更新 URL，URL 变化（如后退/前进）更新内部状态。</li>
<li><strong>SSR 水合问题</strong>：服务端与客户端渲染的 DOM 结构必须完全一致。</li>
<li><strong>组件拆分时的 SSR 陷阱</strong>：<code>isMobile</code>、<code>isDesktop</code> 等环境敏感值不能重复调用，否则会导致水合失败。</li>
<li><strong>移动端手势与桌面端键盘</strong>：两套交互逻辑需要兼容并优雅切换。</li>
<li><strong>标签多选</strong>：桌面端用 Ctrl，移动端用显式的“多选模式”开关（避免长按误触）。</li>
</ul>

<h2 id="二-技术选型与项目结构">二、技术选型与项目结构</h2>

<ul>
<li><strong>框架</strong>：Nuxt 4（SSR）</li>
<li><strong>数据源</strong>：Nuxt Content（<code>queryCollection</code>）</li>
<li><strong>UI 组件</strong>：Nuxt UI 的 <code>UBlogPost</code>、<code>UInput</code>、<code>USelect</code>、<code>UPagination</code> 等</li>
<li><strong>工具库</strong>：<code>@vueuse/core</code> 提供 <code>useLocalStorage</code>、<code>useScroll</code>、<code>useEventListener</code> 等</li>
</ul>

<p>项目结构（关键部分）：</p>

<pre><code class="language-bash">nuxt.config.ts
plugins/
  useResponsive.ts         # 响应式判断（ssrWidth: 768）
  useSwipe.ts              # 滑动检测
layouts/
pages/
  index.vue                # 主页面
components/
  docs/
    SearchHeader.vue       # 搜索栏 + 下拉框
    NavigationLevel.vue    # 等级导航
    TagFilter.vue          # 标签云 + 多选模式开关
    List.vue               # 文章列表
    PaginationBar.vue      # 分页组件
composables/
  useResponsive.ts         # 响应式判断（ssrWidth: 768）
  useSwipe.ts              # 滑动检测
utils/
  tags.ts                  # 标签白名单
</code></pre>

<h2 id="三-url-状态同步-手写闭环">三、URL 状态同步：手写闭环</h2>

<h3 id="3-1-核心思路">3.1 核心思路</h3>

<ul>
<li><strong>单一数据源</strong>：所有状态从 <code>route.query</code> 初始化。</li>
<li><strong>双向同步</strong>：<code>watch(route.query)</code> 将 URL 变化同步到内部 ref；<code>watch</code> 内部 ref 变化时调用 <code>router.push</code> 更新 URL。</li>
<li><strong>防抖</strong>：搜索输入防抖，其他立即更新。</li>
</ul>

<h3 id="3-2-代码示例-主文件-index-vue-中的状态定义与同步">3.2 代码示例（主文件 <code>index.vue</code> 中的状态定义与同步）</h3>

<p>双向同步的完整实现（<code>watch(route.query)</code> 同步到内部 ref、<code>pushQuery()</code> 写回 URL）与系列第 1 篇<a href="./nuxt-url-state-guide">《Nuxt 中 URL 与状态双向绑定指南》</a> §2.2 完全一致，此处不再重复。本文只保留页面特有部分：状态定义、标签解析、搜索防抖与&rdquo;视图模式不触发数据请求&rdquo;的取舍。</p>

<pre><code class="language-ts">const route = useRoute()
const router = useRouter()

// 状态定义（全部从 URL 初始化，与第 1 篇相同）
const searchInput = ref(route.query.search?.toString() || &quot;&quot;)
const searchOption = ref(Number(route.query.option) || 1)
const page = ref(Number(route.query.page) || 1)
const size = ref(Number(route.query.size) || 10)
const viewMode = ref(Number(route.query.viewMode) || 1)
const level = ref(route.query.level?.toString() || &quot;&quot;)
const tags = ref&lt;string[]&gt;([])

// 解析 URL 中的标签（支持逗号分隔）
const parseTagsFromQuery = () =&gt; {
  const tagParam = route.query.tag
  tags.value = tagParam
    ? Array.isArray(tagParam)
      ? tagParam
      : tagParam.split(&quot;,&quot;)
    : []
}
parseTagsFromQuery()

// 本页特有：搜索输入防抖 500ms，其他状态立即同步
watchDebounced(
  searchInput,
  () =&gt; {
    page.value = 1
    pushQuery()
  },
  { debounce: 500 },
)

// 本页特有：视图模式仅用于 UI 展示，不应触发数据重新请求，
// 因此不放入下方 watch（详见 §4.1 与第 1 篇 §4 的差异说明）
watch([page, size, viewMode, level, tags], () =&gt; pushQuery())
watch(searchOption, () =&gt; pushQuery())
</code></pre>

<blockquote>
<p><strong>说明</strong>：上文中被引用的 <code>watch(route.query)</code> 双向同步与 <code>pushQuery()</code> 函数体见第 1 篇 §2.2，此处为保证页面代码可独立阅读仅保留调用骨架。</p>
</blockquote>

<h2 id="四-数据获取-useasyncdata-响应式依赖">四、数据获取：useAsyncData + 响应式依赖</h2>

<h3 id="4-1-核心代码">4.1 核心代码</h3>

<pre><code class="language-ts">const { data: docsData, pending } = await useAsyncData(
  &quot;docs-list&quot;,
  async () =&gt; {
    let query = queryCollection(&quot;docs&quot;).order(&quot;date&quot;, &quot;DESC&quot;)
    const keyword = searchInput.value.trim()

    // 搜索条件
    if (keyword) {
      if (searchOption.value === 1) {
        query = query.orWhere((q) =&gt;
          q
            .where(&quot;title&quot;, &quot;LIKE&quot;, `%${keyword}%`)
            .where(&quot;description&quot;, &quot;LIKE&quot;, `%${keyword}%`),
        )
      } else {
        query = query.where(&quot;title&quot;, &quot;LIKE&quot;, `%${keyword}%`)
      }
    }

    // 等级过滤
    if (level.value) {
      query = query.where(&quot;level&quot;, &quot;=&quot;, level.value)
    }

    // 标签过滤（AND 关系）
    if (tags.value.length) {
      query = query.andWhere((q) =&gt; {
        tags.value.forEach((tag) =&gt; {
          q = q.where(&quot;tags&quot;, &quot;LIKE&quot;, `%${tag}%`)
        })
        return q
      })
    }

    // 分页
    const [total, list] = await Promise.all([
      query.count(),
      query
        .skip((page.value - 1) * size.value)
        .limit(size.value)
        .all(),
    ])

    return { total, list }
  },
  {
    // 注意：viewMode 仅用于 UI 展示，不应触发数据重新请求，因此未放入 watch
    watch: [searchInput, searchOption, page, size, level, tags],
  },
)
</code></pre>

<blockquote>
<p><strong>关键点</strong>：<code>watch</code> 数组中直接使用 ref 本身（如 <code>tags</code>），确保数组内部变化能被正确捕获。同时将 <code>viewMode</code> 移出 watch，避免因视图模式切换导致无意义的数据重载。</p>
</blockquote>

<h3 id="4-2-移动端累积列表-无限滚动">4.2 移动端累积列表（无限滚动）</h3>

<pre><code class="language-ts">const docsList = ref&lt;any[]&gt;([])
watch(
  () =&gt; docsData.value?.list,
  (newList) =&gt; {
    if (!newList) return
    // 仅当移动端且 page &gt; 1 时才合并（水合阶段 page=1，不会进入）
    if (isMobile.value &amp;&amp; page.value &gt; 1) {
      const merged = [...docsList.value, ...newList]
      const uniqueMap = new Map(merged.map((item) =&gt; [item.id, item]))
      docsList.value = Array.from(uniqueMap.values())
    } else {
      docsList.value = newList
    }
  },
  { immediate: true },
)
</code></pre>

<blockquote>
<p><strong>说明</strong>：当用户执行搜索、切换等级或标签时，<code>page</code> 会被重置为 1，此时 <code>page.value &gt; 1</code> 条件不满足，<code>docsList</code> 会被重新赋值为新数据，累积列表自动清空，符合预期。</p>
</blockquote>

<h2 id="五-ssr-安全-避免水合失败的黄金法则">五、SSR 安全：避免水合失败的黄金法则</h2>

<p>水合失败的根本原因是服务端与客户端渲染的 DOM 结构不一致。通用原则（初始状态从 URL 读、环境敏感值只在根组件计算、<code>useAsyncData</code> 的 <code>watch</code> 直接用 ref 等）在第 1 篇<a href="./nuxt-url-state-guide">《Nuxt 中 URL 与状态双向绑定指南》</a> §四已有完整论述，此处只列出本文专属的两条增量：</p>

<ol>
<li><strong><code>isFilterVisible</code> 使用 <code>useLocalStorage</code></strong>：它不影响初始 DOM（服务端默认为 false，客户端从 localStorage 读取后更新，但不改变水合结构）。</li>
<li><strong>累积列表逻辑仅在水合完成后才启用</strong>：通过 <code>page.value &gt; 1</code> 条件限制——水合时 <code>page</code> 为 1，不会合并数据，服务端与客户端列表长度一致。</li>
</ol>

<h2 id="六-组件拆分与职责划分">六、组件拆分与职责划分</h2>

<h3 id="6-1-父组件-index-vue-负责">6.1 父组件（<code>index.vue</code>）负责</h3>

<ul>
<li>所有 URL 状态的管理与同步</li>
<li>数据获取（<code>useAsyncData</code>）</li>
<li>全局交互（键盘、手势、滚动）</li>
<li>将 <code>isDesktop</code>、<code>tags</code>、<code>getTagLink</code>、<code>isTagSelected</code>、<code>handleTagClick</code> 等传递给子组件</li>
</ul>

<h3 id="6-2-子组件仅负责展示与事件转发">6.2 子组件仅负责展示与事件转发</h3>

<p>例如 <code>TagFilter.vue</code>，其 <code>isDesktop</code> 由父组件通过 props 传入，确保 SSR 安全：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div&gt;
    &lt;!-- 桌面端提示 --&gt;
    &lt;span v-if=&quot;isDesktop&quot; class=&quot;ml-2 text-xs text-gray-500&quot;&gt;
      Ctrl+点击多选
    &lt;/span&gt;

    &lt;!-- 移动端多选模式开关 --&gt;
    &lt;div v-if=&quot;!isDesktop&quot; class=&quot;flex justify-end mb-2&quot;&gt;
      &lt;button
        @click=&quot;multiSelectMode = !multiSelectMode&quot;
        class=&quot;text-xs px-2 py-1 rounded bg-gray-700 text-gray-300&quot;
        :class=&quot;{ 'bg-blue-600 text-white': multiSelectMode }&quot;
      &gt;
        {{ multiSelectMode ? &quot;退出多选&quot; : &quot;多选模式&quot; }}
      &lt;/button&gt;
    &lt;/div&gt;

    &lt;div class=&quot;w-full flex flex-wrap&quot;&gt;
      &lt;NuxtLink
        v-for=&quot;tag in ALLOWED_TAGS&quot;
        :key=&quot;tag&quot;
        :to=&quot;getTagLink(tag)&quot;
        class=&quot;block p-2 mx-1 nav-link&quot;
        :class=&quot;{ active: isTagSelected(tag) }&quot;
        @click.prevent=&quot;onTagClick(tag, $event)&quot;
      &gt;
        #{{ tag }}
      &lt;/NuxtLink&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
import { ALLOWED_TAGS } from &quot;~/utils/tags&quot;

const { isMobile } = useResponsive() // 仅在子组件内部使用 isMobile 判断移动端分支，不会影响初始 DOM
const props = defineProps({
  isDesktop: { type: Boolean, required: true }, // 从父组件传入
  getTagLink: { type: Function, required: true },
  isTagSelected: { type: Function, required: true },
})

const multiSelectMode = ref(false)
const emit = defineEmits([&quot;tag-click&quot;])

const onTagClick = (tag, event) =&gt; {
  let isMulti = false
  if (isMobile.value) {
    // 移动端：使用多选模式开关
    isMulti = multiSelectMode.value
  } else {
    // 桌面端：按 Ctrl/Cmd 多选
    isMulti = event.ctrlKey || event.metaKey
  }
  emit(&quot;tag-click&quot;, tag, { ctrlKey: isMulti, metaKey: isMulti })
}
&lt;/script&gt;
</code></pre>

<p>父组件中的 <code>handleTagClick</code> 统一处理 URL 更新：</p>

<pre><code class="language-ts">const handleTagClick = (tag: string, event: MouseEvent) =&gt; {
  const isMulti = event.ctrlKey || event.metaKey // 子组件传递的 event 已包含 ctrlKey
  let newTags: string[]
  if (isMulti) {
    newTags = tags.value.includes(tag)
      ? tags.value.filter((t) =&gt; t !== tag)
      : [...tags.value, tag]
  } else {
    newTags = tags.value.includes(tag) ? [] : [tag]
  }
  const query = { ...route.query }
  if (newTags.length) query.tag = newTags.join(&quot;,&quot;)
  else delete query.tag
  query.page = &quot;1&quot;
  router.push({ query })
}
</code></pre>

<blockquote>
<p><strong>注意</strong>：子组件 <code>onTagClick</code> 和父组件 <code>handleTagClick</code> 命名不同，职责清晰，避免混淆。</p>
</blockquote>

<h2 id="七-移动端体验优化">七、移动端体验优化</h2>

<h3 id="7-1-无限滚动与下拉刷新">7.1 无限滚动与下拉刷新</h3>

<p>使用自定义 <code>useSwipe</code> 组合式函数监听上滑/下滑，上滑触发 <code>loadMoreDocs</code>（<code>page += 1</code>），下滑触发 <code>refreshDocs</code>（<code>page = 1</code>）。滚动检测使用 <code>useScroll</code> 判断是否接近底部或顶部。</p>

<p><code>useSwipe</code> 简化实现：</p>

<pre><code class="language-ts">// composables/useSwipe.ts
import { useEventListener } from &quot;@vueuse/core&quot;

export function useSwipe(
  handlers: {
    onUp?: () =&gt; void
    onDown?: () =&gt; void
  },
  { threshold = 60 } = {},
) {
  const touchStartY = ref(0)

  useEventListener(&quot;touchstart&quot;, (e: TouchEvent) =&gt; {
    touchStartY.value = e.touches[0].clientY
  })

  useEventListener(&quot;touchend&quot;, (e: TouchEvent) =&gt; {
    const distance = touchStartY.value - e.changedTouches[0].clientY
    if (distance &gt; threshold) handlers.onUp?.()
    else if (-distance &gt; threshold) handlers.onDown?.()
  })
}
</code></pre>

<h3 id="7-2-多选模式开关">7.2 多选模式开关</h3>

<ul>
<li>移动端没有 Ctrl 键，因此在标签区域右上角增加“多选模式”按钮。</li>
<li>点击按钮进入多选状态，再次点击退出。多选模式下点击标签直接切换选中状态（无需 Ctrl）。</li>
</ul>

<h3 id="7-3-响应式布局">7.3 响应式布局</h3>

<ul>
<li>桌面端：2 列网格，奇数条时最后一条占满。</li>
<li>移动端：1 列网格，隐藏分页组件，使用无限滚动 + “加载更多”按钮（实际上使用手势上滑，但也可保留按钮作为备选）。</li>
</ul>

<h2 id="八-标签系统设计">八、标签系统设计</h2>

<h3 id="8-1-标签白名单">8.1 标签白名单</h3>

<p>为保持标签系统整洁，定义受控词表（<code>utils/tags.ts</code>）：</p>

<pre><code class="language-ts">export const ALLOWED_TAGS = [
  'Nuxt', 'Vue', 'Docker', 'Caddy', 'GitHub Actions', ...
];
</code></pre>

<h3 id="8-2-标签筛选逻辑">8.2 标签筛选逻辑</h3>

<ul>
<li>多标签 <strong>AND</strong> 关系（文章必须包含所有选中标签）。</li>
<li>查询时使用 <code>andWhere</code> + 循环添加条件。</li>
<li>前端通过 <code>tags.value.includes(tag)</code> 判断高亮。</li>
</ul>

<h2 id="九-空状态与用户体验细节">九、空状态与用户体验细节</h2>

<ul>
<li>当筛选结果为空时，显示友好提示和“清除所有筛选”按钮。</li>
<li>加载状态：通过 <code>pending</code> 显示骨架或禁用按钮。</li>
<li>键盘事件：左右键翻页，ESC 失焦。为避免在输入框中误触翻页，全局键盘事件中需判断当前聚焦元素：</li>
</ul>

<pre><code class="language-ts">const isInputFocused = computed(() =&gt; {
  const active = document.activeElement
  return active?.tagName === &quot;INPUT&quot; || active?.tagName === &quot;TEXTAREA&quot;
})

useEventListener(&quot;keydown&quot;, (e) =&gt; {
  if (e.key === &quot;Escape&quot; &amp;&amp; isInputFocused.value) {
    ;(document.activeElement as HTMLElement)?.blur()
    return
  }
  if (isInputFocused.value) return // 输入框聚焦时不响应翻页

  if (e.key === &quot;ArrowLeft&quot; &amp;&amp; hasPrevPage.value) {
    e.preventDefault()
    page.value -= 1
  } else if (e.key === &quot;ArrowRight&quot; &amp;&amp; hasNextPage.value) {
    e.preventDefault()
    page.value += 1
  }
})
</code></pre>

<ul>
<li>移动端手势：上滑加载更多，下滑刷新。</li>
</ul>

<pre><code class="language-ts">// 空状态提示
const emptyStateMessage = computed(() =&gt; {
  const hasSearch = searchInput.value
  const hasLevel = level.value
  const hasTags = tags.value.length
  if (hasSearch || hasLevel || hasTags) {
    return &quot;没有找到符合条件的文档，试试调整筛选条件吧&quot;
  }
  return &quot;还没有文档，请稍后再来&quot;
})

// 清除所有筛选
const clearAllFilters = () =&gt; {
  searchInput.value = &quot;&quot;
  level.value = &quot;&quot;
  tags.value = []
  page.value = 1
  size.value = 10
  viewMode.value = 1
}
</code></pre>

<h2 id="十-踩坑与总结">十、踩坑与总结</h2>

<h3 id="10-1-踩过的坑">10.1 踩过的坑</h3>

<ol>
<li><strong>水合失败</strong>：子组件重复调用 <code>useResponsive</code> 导致 <code>isMobile</code> 不一致。解决方案：将 <code>isDesktop</code> 作为 props 从父组件传递（见第六节）。</li>
<li><strong><code>useAsyncData</code> 的 <code>watch</code> 不响应数组变化</strong>：直接监听 <code>tags</code> 而不是 <code>() =&gt; tags.value</code> 即可解决。</li>
<li><strong><code>viewMode</code> 触发数据请求</strong>：将 <code>viewMode</code> 从 <code>watch</code> 中移除，避免无意义的网络请求。</li>
<li><strong>移动端长按多选</strong>：长按容易误触且需要处理滚动干扰，最终改为显式“多选模式”开关，体验更好。</li>
<li><strong>分页组件在移动端显示过多</strong>：移动端隐藏分页，改用无限滚动 + 加载更多按钮。</li>
<li><strong>空状态重复提示</strong>：底部“已查询到 0 条文档”与空状态重复，移除。</li>
</ol>

<h3 id="10-2-最终成果">10.2 最终成果</h3>

<ul>
<li><strong>功能完整性</strong>：分页、搜索、等级、标签、视图模式、键盘左右键、移动端手势、无限滚动、下拉刷新、空状态。</li>
<li><strong>SSR 安全</strong>：无任何水合错误，服务端与客户端渲染一致。</li>
<li><strong>代码质量</strong>：状态集中管理，组件职责清晰，支持未来扩展。</li>
</ul>

<h2 id="十一-结语">十一、结语</h2>

<p>构建这个文档列表页花了不少时间，但也让我对 Nuxt SSR 的运作机制有了更深的理解。如果你也在类似项目中遇到水合问题或状态同步的困扰，希望这份记录能给你一些启发。所有代码均已在实际项目中稳定运行，欢迎参考。</p>

<h3 id="相关链接">相关链接</h3>

<ul>
<li><a href="./nuxt-url-state-guide">Nuxt 中 URL 与状态双向绑定指南</a></li>
<li><a href="./nuxt-state-persistence-guide">Nuxt 4 中安全实现状态持久化：根治水合失败指南</a></li>
</ul>

<hr>

<p><em>文中代码为实际项目简化版，完整实现请参考项目源码。</em></p>
]]></content:encoded>
      <description><![CDATA[手把手教你用 Nuxt 4 构建一个支持 URL 状态同步、多维度筛选、移动端无限滚动、键盘翻页的文档列表页。包含手写状态管理、SSR 水合问题排查、组件拆分陷阱、标签多选（桌面端 Ctrl/移动端开关）等完整实现，附可复用代码。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Hydration]]></category>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[自托管 Umami 分析服务与 Nuxt 4 项目集成指南（扩展篇）]]></title>
      <link>https://moongate.top/docs/umami-integration-auto-deploy</link>
      <guid isPermaLink="true">https://moongate.top/docs/umami-integration-auto-deploy</guid>
      <pubDate>Wed, 18 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="版本声明">📌 版本声明</h2>

<p>Node.js、pnpm、Docker Engine、Docker Compose、Caddy、GitHub Actions 的版本信息与<a href="./docker-quickstart-auto-deploy">入门篇</a>一致。本文额外涉及：</p>

<table>
<thead>
<tr>
<th>组件</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Nuxt</td>
<td>4.x</td>
<td>前端框架，兼容 Nuxt 3</td>
</tr>

<tr>
<td>nuxt-umami</td>
<td>3.2.1</td>
<td>Umami 集成模块</td>
</tr>

<tr>
<td>PostgreSQL</td>
<td>alpine 最新</td>
<td>Umami 数据库</td>
</tr>

<tr>
<td>Umami</td>
<td>postgresql-latest</td>
<td>分析服务（生产环境建议固定具体版本，如 <code>postgresql-3.0.3</code>）</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="最终目标">🎯 最终目标</h2>

<ul>
<li>在现有 Docker Compose 环境中添加 Umami 服务（应用 + PostgreSQL 数据库）。</li>
<li>通过 Caddy 自动 HTTPS 暴露 <code>umami.你的域名.com</code>。</li>
<li>环境变量安全注入，数据持久化。</li>
<li>在 Nuxt 项目中通过 <code>nuxt-umami</code> 模块自动加载跟踪脚本，并在每次部署时自动更新配置。</li>
<li>强化生产环境安全，提供 IP 白名单、防火墙、备份等建议。</li>
</ul>

<hr>

<h2 id="系统架构图">🏗️ 系统架构图</h2>

<pre><code class="language-text">┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 本地开发 │────▶│ GitHub Actions │────▶│ 阿里云 ACR │
└─────────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌───────────────────────────────────────────────────────┐
│ 阿里云 ECS │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ PostgreSQL │◀──▶│ Umami │◀──▶│ Caddy │ │
│ │ (umami-db) │ │ (umami) │ │ (caddy) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ▲ ▲ ▲ │
│ └─────────────────┼──────────────────┘ │
│ 同一网络 `app-network` │
└───────────────────────────────────────────────────────┘
</code></pre>

<p><strong>说明</strong>：网络名 <code>app-network</code> 与进阶篇保持一致，用户可根据实际情况自定义，但需确保所有服务在同一网络。</p>

<hr>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>完成进阶篇</strong>，已有基于 Docker Compose 的 Nuxt 项目生产环境（含 Caddy、PostgreSQL、应用容器），项目名统一为 <code>my-app</code>，网络名为 <code>app-network</code>。</li>
<li><strong>一个子域名</strong>（例如 <code>umami.your-domain.com</code>）已添加 A 记录指向服务器 IP。</li>
<li><strong>阿里云 ACR</strong> 已配置好命名空间和固定密码。</li>
<li><strong>服务器安全组</strong>开放 <code>80</code>、<code>443</code> 端口。</li>
<li><strong>GitHub Secrets</strong> 已包含进阶篇所需的所有变量（数据库密码、应用密钥等）。</li>
</ol>

<hr>

<h2 id="第一部分-docker-compose-中添加-umami-服务">🚀 第一部分：Docker Compose 中添加 Umami 服务</h2>

<p>编辑服务器上的 <code>/var/www/my-app/docker-compose.yml</code>，在 <code>services</code> 段末尾添加 Umami 及其数据库。<strong>注意</strong>：如果原文件中已定义 <code>networks</code> 和 <code>volumes</code>，请合并而非重复添加。</p>

<details>
<summary>点击展开完整代码</summary>

<pre><code class="language-yaml">name: my-app

services:
  # 原有服务（postgres, app, caddy）保持不变，此处省略...
  # 注意：请将以下服务名“app”替换为您实际的主应用服务名（应与进阶篇一致）

  umami-db:
    image: postgres:alpine
    container_name: my-app-umami-db
    restart: always
    environment:
      POSTGRES_DB: ${UMAMI_DB_NAME}
      POSTGRES_USER: ${UMAMI_DB_USER}
      POSTGRES_PASSWORD: ${UMAMI_DB_PASSWORD}
    volumes:
      - umami_db_data:/var/lib/postgresql/data
    networks:
      - app-network
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${UMAMI_DB_USER} -d ${UMAMI_DB_NAME}&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest # 生产环境建议固定版本，如 postgresql-3.0.3
    container_name: my-app-umami
    restart: always
    depends_on:
      umami-db:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://${UMAMI_DB_USER}:${UMAMI_DB_PASSWORD}@umami-db:5432/${UMAMI_DB_NAME}
      DATABASE_TYPE: postgresql
      APP_SECRET: ${UMAMI_APP_SECRET}
    networks:
      - app-network
    healthcheck:
      test:
        [
          &quot;CMD&quot;,
          &quot;node&quot;,
          &quot;-e&quot;,
          &quot;require(&apos;http&apos;).get(&apos;http://localhost:3000&apos;, (r) =&gt; {process.exit(r.statusCode === 200 ? 0 : 1)})&quot;,
        ]
      interval: 30s
      timeout: 5s
      retries: 3
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;

networks:
  app-network:
    driver: bridge
    # 如果原文件已定义，此处无需重复

volumes:
  postgres_data:
  caddy_data:
  caddy_config:
  umami_db_data:
  # 如果原文件已定义相应卷，此处只需追加 umami_db_data</code></pre>

</details>

<h3 id="关键点">关键点</h3>

<ul>
<li>所有服务使用同一网络 <code>app-network</code>，通过服务名通信（<code>umami-db</code> 和 <code>umami</code>）。</li>
<li>数据库和应用均配置健康检查，确保启动顺序。</li>
<li>日志切割防止磁盘爆满。</li>
<li><code>APP_SECRET</code> 用于加密会话，必须为足够长的随机字符串（建议使用十六进制生成，见下文）。</li>
<li>生产环境应避免使用 <code>latest</code> 标签，建议固定具体版本（如 <code>postgresql-3.0.3</code>），以确保稳定性。</li>
</ul>

<hr>

<h2 id="第二部分-caddy-子域名配置">🌐 第二部分：Caddy 子域名配置</h2>

<p>编辑 <code>/var/www/my-app/Caddyfile</code>，添加 <code>umami.your-domain.com</code> 配置块。请将 <code>your-domain.com</code> 替换为实际域名，并将 <code>reverse_proxy</code> 目标指向 Umami 容器服务名 <code>umami:3000</code>（而非主应用）。</p>

<pre><code class="language-caddy">umami.your-domain.com {
    reverse_proxy umami:3000   # 关键：指向 umami 容器
    encode gzip zstd
    header {
        X-Content-Type-Options &quot;nosniff&quot;
        X-Frame-Options &quot;DENY&quot;
        X-XSS-Protection &quot;1; mode=block&quot;
        Referrer-Policy &quot;strict-origin-when-cross-origin&quot;
        Strict-Transport-Security &quot;max-age=31536000; includeSubDomains; preload&quot;
    }
}
</code></pre>

<p><strong>说明</strong>：访问控制（如 IP 白名单）将在安全优化部分单独添加，此处仅配置基本代理和安全头。</p>

<p>保存后重启 Caddy 容器使配置生效：</p>

<pre><code class="language-bash">cd /var/www/my-app
docker compose restart caddy
</code></pre>

<hr>

<h2 id="第三部分-环境变量与密钥管理">🔐 第三部分：环境变量与密钥管理</h2>

<h3 id="3-1-生成-umami-所需密码和密钥">3.1 生成 Umami 所需密码和密钥</h3>

<p>在服务器上执行以下命令生成强密码（<strong>推荐使用十六进制，避免特殊字符</strong>）：</p>

<pre><code class="language-bash"># 生成数据库密码（32位十六进制）
openssl rand -hex 16
# 生成 APP_SECRET（64位十六进制）
openssl rand -hex 32
</code></pre>

<blockquote>
<p><strong>注意</strong>：使用 <code>-hex</code> 生成的字符串只包含 <code>0-9a-f</code>，可安全用于环境变量，无需担心 shell 转义问题。</p>
</blockquote>

<h3 id="3-2-更新服务器-env-文件">3.2 更新服务器 <code>.env</code> 文件</h3>

<p>编辑 <code>/var/www/my-app/.env</code>，添加以下变量（使用生成的值替换占位符）：</p>

<pre><code class="language-bash"># Umami 配置
UMAMI_DB_NAME=umami
UMAMI_DB_USER=umami
UMAMI_DB_PASSWORD=your-generated-db-password
UMAMI_APP_SECRET=your-generated-app-secret

# 用于 Nuxt 的环境变量（稍后会在 CI 中写入）
NUXT_PUBLIC_UMAMI_ID=待填入
NUXT_PUBLIC_UMAMI_HOST=https://umami.your-domain.com
</code></pre>

<p>保存后设置权限：</p>

<pre><code class="language-bash">chmod 600 /var/www/my-app/.env
</code></pre>

<h3 id="3-3-在-github-secrets-中添加变量">3.3 在 GitHub Secrets 中添加变量</h3>

<p>进入 GitHub 仓库 → <strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong>，添加以下 Secrets：</p>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>UMAMI_DB_NAME</code></td>
<td>固定为 <code>umami</code></td>
</tr>

<tr>
<td><code>UMAMI_DB_USER</code></td>
<td>固定为 <code>umami</code></td>
</tr>

<tr>
<td><code>UMAMI_DB_PASSWORD</code></td>
<td>生成的数据库密码</td>
</tr>

<tr>
<td><code>UMAMI_APP_SECRET</code></td>
<td>生成的 APP_SECRET</td>
</tr>

<tr>
<td><code>NUXT_PUBLIC_UMAMI_ID</code></td>
<td>稍后从 Umami 后台获取（暂留空）</td>
</tr>

<tr>
<td><code>NUXT_PUBLIC_UMAMI_HOST</code></td>
<td><code>https://umami.your-domain.com</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第四部分-首次启动-umami-并获取-website-id">🚀 第四部分：首次启动 Umami 并获取 Website ID</h2>

<blockquote>
<p><strong>重要提示</strong>：本步骤需<strong>先手动执行一次</strong>，获取 Website ID 后更新 GitHub Secrets，再触发 CI 部署包含该 ID 的应用。不能在一次 CI 中完成所有步骤。</p>
</blockquote>

<h3 id="4-1-启动-umami-服务">4.1 启动 Umami 服务</h3>

<pre><code class="language-bash">cd /var/www/my-app
docker compose up -d umami-db umami
docker compose logs -f umami   # 观察日志，等待启动成功（看到 &quot;Ready in&quot; 字样）
</code></pre>

<h3 id="4-2-访问后台并添加网站">4.2 访问后台并添加网站</h3>

<ul>
<li>浏览器打开 <code>https://umami.your-domain.com</code>。</li>
<li>默认登录账号：<code>admin</code>，密码：<code>umami</code>。</li>
</ul>

<blockquote>
<p>⚠️ <strong>立即修改默认密码</strong>：登录后进入 <strong>Settings</strong> → <strong>Profile</strong>，将密码更改为强密码。
- 点击 <strong>Settings</strong> → <strong>Websites</strong> → <strong>Add Website</strong>，填写：
  - Name：<code>Your Site Name</code>（如 <code>My Blog</code>）
  - Domain：<code>your-domain.com</code>（主站域名）
- 保存后，复制生成的 <strong>Website ID</strong>（UUID 格式）。</p>
</blockquote>

<h3 id="4-3-更新-github-secrets">4.3 更新 GitHub Secrets</h3>

<p>将复制的 Website ID 填入 GitHub Secrets 中的 <code>NUXT_PUBLIC_UMAMI_ID</code>。</p>

<hr>

<h2 id="第五部分-nuxt-项目集成-nuxt-umami">🧩 第五部分：Nuxt 项目集成 <code>nuxt-umami</code></h2>

<h3 id="5-1-安装模块">5.1 安装模块</h3>

<p>在本地项目根目录执行：</p>

<pre><code class="language-bash">pnpm add nuxt-umami
# 或使用 nuxi 添加
pnpx nuxi@latest module add nuxt-umami
</code></pre>

<h3 id="5-2-配置-nuxt-config-ts">5.2 配置 <code>nuxt.config.ts</code></h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;nuxt-umami&quot;],
  umami: {
    id: process.env.NUXT_PUBLIC_UMAMI_ID,
    host: process.env.NUXT_PUBLIC_UMAMI_HOST,
    autoTrack: true,
    // 可选：仅在非开发环境启用
    enabled: process.env.NODE_ENV !== &quot;development&quot;,
  },
  // 其他配置...
})
</code></pre>

<h3 id="5-3-本地测试-可选">5.3 本地测试（可选）</h3>

<p>在项目根目录创建 <code>.env</code> 文件（<strong>不提交 Git</strong>）：</p>

<pre><code class="language-bash">NUXT_PUBLIC_UMAMI_ID=your-website-id
NUXT_PUBLIC_UMAMI_HOST=https://umami.your-domain.com
</code></pre>

<p>运行 <code>pnpm dev</code>，访问 <code>http://localhost:3000</code>，打开开发者工具 → Network，应能看到 Umami 脚本请求。</p>

<hr>

<h2 id="第六部分-dockerfile-必须接收构建参数">🔧 第六部分：Dockerfile 必须接收构建参数</h2>

<h3 id="关键">关键</h3>

<p><code>NUXT_PUBLIC_*</code> 变量在构建时被嵌入客户端代码，必须在 Docker 构建阶段通过 <code>ARG</code> 和 <code>ENV</code> 传递。</p>

<p>编辑项目根目录的 <code>Dockerfile</code>，确保包含以下内容：</p>

<pre><code class="language-dockerfile"># 构建阶段
FROM node:24-alpine AS builder

# 接收所有 NUXT_PUBLIC_* 变量
ARG NUXT_PUBLIC_SITE_URL
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL

ARG NUXT_PUBLIC_UMAMI_ID
ENV NUXT_PUBLIC_UMAMI_ID=$NUXT_PUBLIC_UMAMI_ID

ARG NUXT_PUBLIC_UMAMI_HOST
ENV NUXT_PUBLIC_UMAMI_HOST=$NUXT_PUBLIC_UMAMI_HOST

# 其余构建步骤保持不变...
</code></pre>

<h3 id="提醒">提醒</h3>

<p>若后续增加其他 <code>NUXT_PUBLIC_*</code> 变量，必须同步添加 <code>ARG</code> 和 <code>ENV</code>。</p>

<hr>

<h2 id="第七部分-github-actions-工作流完善">⚙️ 第七部分：GitHub Actions 工作流完善</h2>

<h3 id="7-1-在-build-push-步骤中传递构建参数">7.1 在 <code>build-push</code> 步骤中传递构建参数</h3>

<p>编辑 <code>.github/workflows/deploy.yml</code>，在 <code>docker/build-push-action</code> 步骤的 <code>build-args</code> 中添加 Umami 变量：</p>

<pre><code class="language-yaml">- name: Build and push Docker image
  uses: docker/build-push-action@v5
  with:
    context: .
    push: true
    build-args: |
      NUXT_PUBLIC_SITE_URL=${{ secrets.NUXT_PUBLIC_SITE_URL }}
      NUXT_PUBLIC_UMAMI_ID=${{ secrets.NUXT_PUBLIC_UMAMI_ID }}
      NUXT_PUBLIC_UMAMI_HOST=${{ secrets.NUXT_PUBLIC_UMAMI_HOST }}
    tags: |
      ${{ secrets.ACR_REGISTRY }}/my-app:latest
      ${{ secrets.ACR_REGISTRY }}/my-app:${{ github.sha }}
</code></pre>

<h3 id="7-2-在-ssh-部署脚本中写入-env-文件">7.2 在 SSH 部署脚本中写入 <code>.env</code> 文件</h3>

<p>在<a href="./docker-production-auto-deploy">进阶篇</a>的 <code>appleboy/ssh-action</code> 步骤基础上，<strong>新增以下 Umami 变量</strong>（<code>env</code> 和 <code>envs</code> 两部分都需添加）。<code>SCRIPT</code> 中基础变量写入逻辑与进阶篇相同，只需在 <code>cat &gt; .env</code> 中<strong>追加 Umami 变量段落</strong>，并在拉取/重启命令中<strong>追加 umami 服务</strong>：</p>

<pre><code class="language-yaml"># 新增到 env（与进阶篇原有变量并列）
UMAMI_DB_NAME: ${{ secrets.UMAMI_DB_NAME }}
UMAMI_DB_USER: ${{ secrets.UMAMI_DB_USER }}
UMAMI_DB_PASSWORD: ${{ secrets.UMAMI_DB_PASSWORD }}
UMAMI_APP_SECRET: ${{ secrets.UMAMI_APP_SECRET }}
NUXT_PUBLIC_UMAMI_ID: ${{ secrets.NUXT_PUBLIC_UMAMI_ID }}
NUXT_PUBLIC_UMAMI_HOST: ${{ secrets.NUXT_PUBLIC_UMAMI_HOST }}

# envs 列表在原有基础上追加：
# UMAMI_DB_NAME, UMAMI_DB_USER, UMAMI_DB_PASSWORD, UMAMI_APP_SECRET,
# NUXT_PUBLIC_UMAMI_ID, NUXT_PUBLIC_UMAMI_HOST

# script 中的差异化部分：
cat &gt; .env &lt;&lt; EOF
# (原有变量，与进阶篇相同：POSTGRES_*、ACR_REGISTRY、NUXT_*)

# Umami 变量（追加）
UMAMI_DB_NAME=$UMAMI_DB_NAME
UMAMI_DB_USER=$UMAMI_DB_USER
UMAMI_DB_PASSWORD=$UMAMI_DB_PASSWORD
UMAMI_APP_SECRET=$UMAMI_APP_SECRET
NUXT_PUBLIC_UMAMI_ID=$NUXT_PUBLIC_UMAMI_ID
NUXT_PUBLIC_UMAMI_HOST=$NUXT_PUBLIC_UMAMI_HOST
EOF

chmod 600 .env

# ★ 差异化：额外拉取 umami 镜像
docker compose pull umami

# ★ 差异化：可选手动更新 Umami 容器（会导致短暂停机）
# docker compose up -d --force-recreate umami
</code></pre>

<p><strong>说明</strong>：</p>

<ul>
<li>脚本中 <code>app</code> 请替换为您实际的主应用服务名。</li>
<li><code>envs</code> 列表使用逗号分隔在一行内，避免换行导致解析错误。</li>
<li>每次更新 <code>NUXT_PUBLIC_UMAMI_ID</code> 或 <code>UMAMI_*</code> 变量时，需确保它们已包含在 <code>envs</code> 和 <code>env</code> 部分中。</li>
</ul>

<hr>

<h2 id="第八部分-验证集成">✅ 第八部分：验证集成</h2>

<h3 id="8-1-检查网络请求">8.1 检查网络请求</h3>

<ul>
<li>访问 <code>https://your-domain.com</code>，打开开发者工具 → <strong>Network</strong> 标签，刷新页面。</li>
<li>过滤 <code>umami</code> 或 <code>api/send</code>，应能看到：

<ul>
<li>一个指向 <code>https://umami.your-domain.com/script.js</code> 的 GET 请求（加载跟踪脚本）。</li>
<li>一个指向 <code>https://umami.your-domain.com/api/send</code> 的 POST 请求（发送页面视图数据）。</li>
</ul></li>
</ul>

<h3 id="8-2-查看-umami-后台实时数据">8.2 查看 Umami 后台实时数据</h3>

<p>登录 <code>https://umami.your-domain.com</code>，进入 <strong>Realtime</strong> 页面，应显示当前访问记录。</p>

<h3 id="8-3-验证-window-umami-对象">8.3 验证 <code>window.umami</code> 对象</h3>

<p>在浏览器控制台输入 <code>window.umami</code>，应返回包含 <code>track</code>、<code>identify</code> 等方法的对象。</p>

<hr>

<h2 id="第九部分-安全优化建议">🔒 第九部分：安全优化建议</h2>

<h3 id="9-1-限制-umami-子域名访问范围">9.1 限制 Umami 子域名访问范围</h3>

<h4 id="9-1-1-ip-白名单-推荐">9.1.1 IP 白名单（推荐）</h4>

<p>如果仅允许特定 IP（如家庭宽带）访问 Umami 后台，可在 Caddy 中添加 IP 白名单。</p>

<p>编辑 Caddyfile，修改 <code>umami.your-domain.com</code> 块：</p>

<pre><code class="language-caddy">umami.your-domain.com {
    @allowed remote_ip 192.0.2.1 2001:db8::1 127.0.0.1
    handle @allowed {
        reverse_proxy umami:3000   # 指向 umami 容器
    }
    handle {
        respond &quot;Access Denied&quot; 403
    }

    encode gzip zstd
    header { ... }
}
</code></pre>

<p>将 <code>192.0.2.1</code> 和 <code>2001:db8::1</code> 替换为实际家庭或办公室的公网 IP（IPv4 和 IPv6）。注意家庭宽带 IP 可能变化，建议配合 DDNS 或定期更新。</p>

<h4 id="9-1-2-基础认证-可选">9.1.2 基础认证（可选）</h4>

<p>若需在移动网络下访问，可启用 Caddy 的 <code>basicauth</code>，但需注意与 Umami 自身登录页面的潜在冲突。正确配置方法：</p>

<ol>
<li>生成密码哈希（使用 <code>caddy hash-password</code> 命令）：</li>
</ol>

<pre><code class="language-bash">   docker exec my-app-caddy caddy hash-password --plaintext 'your-password'
</code></pre>

<p><strong>注意</strong>：将命令输出的字符串用<strong>单引号</strong>包裹后填入 Caddyfile，例如 <code>admin '$2a$...'</code>，以防止 <code>$</code> 被解析为环境变量。</p>

<ol>
<li>在 Caddyfile 中添加：</li>
</ol>

<pre><code class="language-caddy">umami.your-domain.com {
    basicauth * {
        admin '$2a$14$...'   # 使用单引号包裹哈希值
    }
    reverse_proxy umami:3000 {
        header_up -Authorization  # 移除 Authorization 头，避免干扰 Umami 会话
    }
    # ... 其他配置
}
</code></pre>

<h5 id="警告">警告</h5>

<p>这会导致浏览器先弹出 Basic Auth 对话框，通过后再显示 Umami 登录页，可能造成体验不佳。建议仅作为临时方案或与 IP 白名单结合使用。</p>

<h3 id="9-2-数据库安全">9.2 数据库安全</h3>

<ul>
<li>使用强密码（已使用 <code>openssl rand -hex</code> 生成）。</li>
<li>Umami 数据库仅对内部网络暴露，无需映射端口。</li>
<li>定期备份数据库（确保 <code>backups</code> 目录存在）：</li>
</ul>

<pre><code class="language-bash">  mkdir -p /var/www/my-app/backups
  docker exec my-app-umami-db pg_dump -U umami umami &gt; /var/www/my-app/backups/umami_$(date +%Y%m%d).sql
</code></pre>

<h3 id="9-3-caddy-安全头增强">9.3 Caddy 安全头增强</h3>

<p>在 Caddyfile 中添加更严格的 CSP 头（需根据实际资源调整，生产环境建议移除 <code>'unsafe-inline'</code> 并采用 nonce 或 hash 策略）：</p>

<pre><code class="language-caddy">header {
    Content-Security-Policy &quot;default-src 'self'; script-src 'self' https://umami.your-domain.com; style-src 'self'; img-src 'self' data:; connect-src 'self' https://umami.your-domain.com;&quot;
    # 其他头...
}
</code></pre>

<h3 id="9-4-日志配置与轮转">9.4 日志配置与轮转</h3>

<p>Caddy 日志可输出到文件并配置轮转。需提前创建日志目录并设置权限（Caddy 容器内用户 UID 为 1000）：</p>

<pre><code class="language-bash">sudo mkdir -p /var/log/caddy
sudo chown 1000:1000 /var/log/caddy
</code></pre>

<p>然后在 Caddyfile 中添加：</p>

<pre><code class="language-caddy">log {
    output file /var/log/caddy/umami-access.log {
        roll_size 10MB
        roll_keep 5
    }
}
</code></pre>

<p>若不想处理文件权限，可直接输出到标准输出（由 Docker 收集）。</p>

<h3 id="9-5-防火墙配置">9.5 防火墙配置</h3>

<ul>
<li>在阿里云安全组中，仅开放 80、443 端口给公网，22 端口限制为特定管理 IP。</li>
<li>在服务器内部使用 <code>ufw</code> 进一步限制：</li>
</ul>

<pre><code class="language-bash">  sudo ufw default deny incoming
  sudo ufw default allow outgoing
  sudo ufw allow 22/tcp  # 建议限制来源 IP
  sudo ufw allow 80/tcp
  sudo ufw allow 443/tcp
  sudo ufw enable
</code></pre>

<hr>

<h2 id="第十部分-深度问题排查手册">🧪 第十部分：深度问题排查手册</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Umami 容器不断重启，日志显示 <code>password authentication failed</code></strong></td>
<td>数据库密码与 <code>.env</code> 不一致，或密码含特殊字符</td>
<td>检查 <code>.env</code> 中 <code>UMAMI_DB_PASSWORD</code> 是否匹配；使用字母数字密码；删除卷重建：<code>docker compose down -v umami-db umami &amp;&amp; docker compose up -d umami-db umami</code></td>
</tr>

<tr>
<td><strong>访问 <code>umami.your-domain.com</code> 返回 502</strong></td>
<td>Umami 容器未就绪，或 Caddy 代理配置错误</td>
<td>检查 <code>docker compose ps umami</code> 状态；查看 Caddy 日志；测试内部连通性：<code>docker exec my-app-caddy wget -O- http://umami:3000</code></td>
</tr>

<tr>
<td><strong>浏览器中 Umami 脚本未加载，Network 中无请求</strong></td>
<td>构建时未传递 <code>NUXT_PUBLIC_UMAMI_*</code> 变量</td>
<td>检查 Dockerfile 是否包含对应的 <code>ARG</code>/<code>ENV</code>；检查 GitHub Actions 日志中 <code>build-args</code> 是否传递</td>
</tr>

<tr>
<td><strong>后台无数据，但脚本已加载</strong></td>
<td>Website ID 错误，或广告拦截器阻止</td>
<td>核对 <code>NUXT_PUBLIC_UMAMI_ID</code>；在无痕模式下测试</td>
</tr>

<tr>
<td><strong>本地开发环境报 <code>id is missing</code></strong></td>
<td>本地未设置环境变量</td>
<td>可忽略，或使用 <code>enabled: process.env.NODE_ENV !== 'development'</code> 禁用</td>
</tr>

<tr>
<td><strong>IP 白名单不生效，所有 IP 均可访问</strong></td>
<td>Caddy 未重新加载配置 / <code>remote_ip</code> 匹配器语法错误</td>
<td>重启 Caddy 容器；检查 <code>@allowed</code> 定义中 IP 格式是否正确</td>
</tr>

<tr>
<td><strong>Basic Auth 配置后页面转圈</strong></td>
<td><code>Authorization</code> 头干扰 Umami 会话 / 浏览器缓存</td>
<td>添加 <code>header_up -Authorization</code> 到 <code>reverse_proxy</code>；清除浏览器缓存</td>
</tr>

<tr>
<td><strong>Caddy 无法写入日志文件</strong></td>
<td>宿主机目录权限不足</td>
<td>确保目录存在且 UID 1000 有写权限：<code>sudo chown 1000:1000 /var/log/caddy</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第十一部分-日常运维">🛠️ 第十一部分：日常运维</h2>

<h3 id="11-1-常用命令">11.1 常用命令</h3>

<p><code>docker compose ps</code>、<code>logs -f</code>、<code>exec</code> 等基础命令请参见<a href="./docker-quickstart-auto-deploy">入门篇 附录</a>。本文特有的运维命令：</p>

<pre><code class="language-bash"># 备份 Umami 数据库（确保 backups 目录存在）
mkdir -p backups
docker exec my-app-umami-db pg_dump -U umami umami &gt; backups/umami_$(date +%Y%m%d).sql

# 恢复数据库
cat backups/umami_20260318.sql | docker exec -i my-app-umami-db psql -U umami -d umami

# 手动更新 Umami 镜像（拉取最新版本并重启）
docker compose pull umami &amp;&amp; docker compose up -d umami
</code></pre>

<h3 id="11-2-自动备份-可选">11.2 自动备份（可选）</h3>

<p>添加定时任务（crontab -e）：</p>

<pre><code class="language-bash">0 3 * * * cd /var/www/my-app &amp;&amp; mkdir -p backups &amp;&amp; docker exec my-app-umami-db pg_dump -U umami umami &gt; backups/umami_$(date +\%Y\%m\%d).sql
</code></pre>

<hr>

<h2 id="总结">🏁 总结</h2>

<p>通过本指南，读者成功实现了：</p>

<ul>
<li>在现有 Docker 生产环境中自托管 Umami 分析服务。</li>
<li>通过 Caddy 自动 HTTPS 暴露子域名。</li>
<li>环境变量安全注入与 CI/CD 自动化部署。</li>
<li>Nuxt 项目正确集成 <code>nuxt-umami</code> 模块，并在构建时传递必要变量。</li>
<li>生产级安全加固措施，保障服务仅被授权访问。</li>
</ul>

<p>现在，网站访问数据将被清晰记录，且整个过程完全自动化，无需人工干预。后续如需升级 Umami 或修改配置，只需修改 <code>docker-compose.yml</code> 中的镜像标签或环境变量，重新部署即可。</p>

<h3 id="核心维护要点">核心维护要点</h3>

<ul>
<li>定期更新 Umami 镜像以获取安全补丁（建议测试后再更新）。</li>
<li>监控日志和磁盘使用情况。</li>
<li>若家庭 IP 变化，及时更新 IP 白名单。</li>
<li>妥善保管 <code>.env</code> 文件和 GitHub Secrets。</li>
<li>确保 <code>envs</code> 列表始终包含所有新增的环境变量，避免部署时丢失。</li>
</ul>
]]></content:encoded>
      <description><![CDATA[在现有 Docker 生产环境中集成自托管的 Umami 分析服务，通过 Caddy 自动 HTTPS 和 GitHub Actions 实现 Nuxt 4 项目的自动化数据跟踪。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Docker 生产级自动化部署（进阶篇）]]></title>
      <link>https://moongate.top/docs/docker-production-auto-deploy</link>
      <guid isPermaLink="true">https://moongate.top/docs/docker-production-auto-deploy</guid>
      <pubDate>Mon, 16 Mar 2026 23:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="版本声明">📌 版本声明</h2>

<p>所有工具的版本信息与<a href="./docker-quickstart-auto-deploy">入门篇</a>一致（Docker Engine 29.x、Docker Compose v5、PostgreSQL 17 alpine、Drizzle ORM 0.30+、PM2 5+、Node.js 24.x、Caddy 2.8+）。</p>

<hr>

<h2 id="本章目标">🎯 本章目标</h2>

<p>在入门篇的基础上，你将学会：</p>

<ul>
<li>✅ 多容器生产级编排（应用 + 数据库 + 反向代理）</li>
<li>✅ 健康检查与容器启动顺序控制</li>
<li>✅ 容器网络与服务发现（通过服务名通信）</li>
<li>✅ 环境变量安全传递（GitHub Secrets + 服务器 <code>.env</code> 权限）</li>
<li>✅ 数据持久化与自动备份</li>
<li>✅ 数据库迁移自动化（以 Drizzle ORM 为例）</li>
<li>✅ 零停机部署策略（解决端口冲突）</li>
<li>✅ 镜像加速器配置与国内优化</li>
<li>✅ 常见问题深度排查手册</li>
</ul>

<p>最终你将拥有一套<strong>可上生产、自动修复、安全可控</strong>的 Docker 部署流水线。</p>

<hr>

<h2 id="系统架构图">🏗️ 系统架构图</h2>

<pre><code class="language-text">┌─────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  本地开发    │────▶│  GitHub Actions │────▶│   阿里云 ACR    │
└─────────────┘     └─────────────────┘     └─────────────────┘
                                                      │
                                                      ▼
┌───────────────────────────────────────────────────────┐
│                    阿里云 ECS                          │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐ │
│  │  PostgreSQL │◀──▶│  Nuxt 应用  │◀──▶│    Caddy    │ │
│  │   (容器)    │    │   (容器)    │    │   (容器)    │ │
│  └─────────────┘    └─────────────┘    └─────────────┘ │
│         ▲                 ▲                  ▲         │
│         └─────────────────┼──────────────────┘         │
│                   同一网络 `app-network`                │
└───────────────────────────────────────────────────────┘
</code></pre>

<hr>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>完成入门篇</strong>，已能跑通单容器部署。</li>
<li><strong>一个域名</strong>（例如 <code>your-domain.com</code>）并解析到服务器 IP。</li>
<li><strong>阿里云 ACR</strong> 已配置好命名空间和固定密码。</li>
<li><strong>服务器安全组</strong>开放 <code>80</code>、<code>443</code>、<code>22</code> 端口。</li>
<li><strong>项目已集成 Drizzle ORM</strong>，并生成迁移文件（已提交至 Git）。</li>
</ol>

<hr>

<h2 id="第一部分-生产级-docker-compose-配置">🚀 第一部分：生产级 Docker Compose 配置</h2>

<h3 id="1-1-目录结构">1.1 目录结构</h3>

<pre><code class="language-text">/var/www/my-app/
├── docker-compose.yml
├── .env                  # 环境变量（手动创建，不提交 Git）
├── Caddyfile             # Caddy 配置
└── backups/              # 数据库备份目录（可选）
</code></pre>

<h3 id="1-2-编写生产级-docker-compose-yml">1.2 编写生产级 docker-compose.yml</h3>

<details>
<summary>点击展开完整代码</summary>

<pre><code class="language-yaml">name: my-app # 固定项目名，避免网络混乱

services:
  postgres:
    image: postgres:alpine
    container_name: my-app-db
    restart: always
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - app-network
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    # logging 为入门篇没有的生产增强，所有服务统一使用：
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;

  app:
    image: ${ACR_REGISTRY}/my-app:latest
    container_name: my-app
    restart: always
    # 不暴露端口到宿主机，仅内部网络访问（由 Caddy 代理）
    environment:
      NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL}
      NUXT_SESSION_PASSWORD: ${NUXT_SESSION_PASSWORD}
      NUXT_OAUTH_GITHUB_CLIENT_ID: ${NUXT_OAUTH_GITHUB_CLIENT_ID}
      NUXT_OAUTH_GITHUB_CLIENT_SECRET: ${NUXT_OAUTH_GITHUB_CLIENT_SECRET}
      NUXT_DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - app-network
    healthcheck:
      test:
        [
          &quot;CMD&quot;,
          &quot;node&quot;,
          &quot;-e&quot;,
          &quot;require(&apos;http&apos;).get(&apos;http://localhost:3000&apos;, (r) =&gt; {process.exit(r.statusCode === 200 ? 0 : 1)})&quot;,
        ]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 15s
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;

  caddy:
    image: caddy:alpine
    container_name: my-app-caddy
    restart: always
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - app-network
    depends_on:
      app:
        condition: service_healthy
    logging:
      driver: &quot;json-file&quot;
      options:
        max-size: &quot;10m&quot;
        max-file: &quot;3&quot;

volumes:
  postgres_data:
  caddy_data:
  caddy_config:

networks:
  app-network:
    driver: bridge</code></pre>

</details>

<h4 id="关键点">关键点</h4>

<ul>
<li><code>name</code> 固定项目名，避免因目录名变化导致网络不一致。</li>
<li>所有服务在同一网络，通过服务名通信。</li>
<li>应用容器不暴露端口，流量全走 Caddy，提升安全性。</li>
<li><code>depends_on</code> + <code>condition: service_healthy</code> 确保启动顺序。</li>
<li>日志切割防止磁盘爆满。</li>
</ul>

<h3 id="1-3-caddyfile-配置-自动-https">1.3 Caddyfile 配置（自动 HTTPS）</h3>

<p>请将 <code>your-domain.com</code> 替换为你的实际域名。</p>

<pre><code class="language-caddy">www.your-domain.com {
    redir https://your-domain.com{uri} permanent
}

your-domain.com {
    reverse_proxy app:3000
    encode gzip zstd
    header {
        X-Content-Type-Options &quot;nosniff&quot;
        X-Frame-Options &quot;DENY&quot;
        X-XSS-Protection &quot;1; mode=block&quot;
        Referrer-Policy &quot;strict-origin-when-cross-origin&quot;
    }
}
</code></pre>

<hr>

<h2 id="第二部分-环境变量安全">🔐 第二部分：环境变量安全</h2>

<h3 id="2-1-github-secrets-完整列表">2.1 GitHub Secrets 完整列表</h3>

<p><code>SERVER_HOST</code>、<code>SERVER_USER</code>、<code>SSH_PRIVATE_KEY</code> 的配置方法与<a href="./docker-quickstart-auto-deploy">入门篇 第三步</a>一致。此外需要：</p>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>ACR_REGISTRY</code></td>
<td>阿里云镜像仓库地址（如 <code>crpi-xxx.cn-beijing.personal.cr.aliyuncs.com</code>）</td>
</tr>

<tr>
<td><code>ACR_USERNAME</code></td>
<td>阿里云账号邮箱</td>
</tr>

<tr>
<td><code>ACR_PASSWORD</code></td>
<td>ACR 固定密码</td>
</tr>

<tr>
<td><code>POSTGRES_DB</code></td>
<td>数据库名</td>
</tr>

<tr>
<td><code>POSTGRES_USER</code></td>
<td>数据库用户</td>
</tr>

<tr>
<td><code>POSTGRES_PASSWORD</code></td>
<td>数据库密码</td>
</tr>

<tr>
<td><code>NUXT_PUBLIC_SITE_URL</code></td>
<td>网站域名（如 <code>https://your-domain.com</code>）</td>
</tr>

<tr>
<td><code>NUXT_SESSION_PASSWORD</code></td>
<td>会话加密密钥（至少 32 位）</td>
</tr>

<tr>
<td><code>NUXT_OAUTH_GITHUB_CLIENT_ID</code></td>
<td>GitHub OAuth Client ID</td>
</tr>

<tr>
<td><code>NUXT_OAUTH_GITHUB_CLIENT_SECRET</code></td>
<td>GitHub OAuth Client Secret</td>
</tr>
</tbody>
</table>

<h3 id="2-2-服务器-env-文件权限">2.2 服务器 <code>.env</code> 文件权限</h3>

<p>在首次部署前，手动创建 <code>/var/www/my-app/.env</code>，并设置权限：</p>

<pre><code class="language-bash">chmod 600 /var/www/my-app/.env
</code></pre>

<p>内容示例（请替换为实际值）：</p>

<pre><code class="language-bash">POSTGRES_DB=myapp
POSTGRES_USER=postgres
POSTGRES_PASSWORD=StrongPassword123!
ACR_REGISTRY=crpi-xxx.cn-beijing.personal.cr.aliyuncs.com
NUXT_PUBLIC_SITE_URL=https://your-domain.com
NUXT_SESSION_PASSWORD=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NUXT_OAUTH_GITHUB_CLIENT_ID=xxxxxxxxxx
NUXT_OAUTH_GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
</code></pre>

<hr>

<h2 id="第三部分-github-actions-工作流-进阶版">⚙️ 第三部分：GitHub Actions 工作流（进阶版）</h2>

<p>以下增量配置是在<a href="./docker-quickstart-auto-deploy">入门篇第四步</a>的完整 workflow 基础上添加的。<code>checkout@v4</code>、<code>Login to ACR</code>、<code>Build and push</code> 等基础步骤与入门篇一致，此处仅展示<strong>差异化部分</strong>：</p>

<pre><code class="language-yaml"># 在入门篇 workflow 基础上：
# 1) Build and push 步骤增加多标签推送与缓存加速
- name: Build and push
  uses: docker/build-push-action@v5
  with:
    push: true
    tags: |
      ${{ secrets.ACR_REGISTRY }}/my-app:latest
      ${{ secrets.ACR_REGISTRY }}/my-app:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

# 2) Deploy 步骤的 .env 写入逻辑中，将基础变量替换为环境变量引用展开（与入门篇相同），并追加 NUXT_* 变量
#    完整 ssh-action 步骤中的 envs 列表需包含：ACR_REGISTRY, ACR_USERNAME, ACR_PASSWORD,
#    POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, NUXT_PUBLIC_SITE_URL,
#    NUXT_SESSION_PASSWORD, NUXT_OAUTH_GITHUB_CLIENT_ID, NUXT_OAUTH_GITHUB_CLIENT_SECRET

# 3) 服务器远程脚本中的差异化命令：
set -e
cd /var/www/my-app

# 基础 .env 写入（与入门篇相同：POSTGRES_* + ACR_REGISTRY）
# 追加 NUXT_* 变量到 .env
chmod 600 .env

# 登录 ACR
echo &quot;$ACR_PASSWORD&quot; | docker login &quot;$ACR_REGISTRY&quot; -u &quot;$ACR_USERNAME&quot; --password-stdin
docker compose pull app

# ★ 差异化：执行数据库迁移（容器内安装 drizzle-kit 后运行）
docker compose run --rm app sh -c &quot;npm install -g drizzle-kit &amp;&amp; drizzle-kit migrate&quot;

# ★ 差异化：重启应用 + Caddy（--force-recreate 强制替换容器）
docker compose up -d --force-recreate app
docker compose up -d --force-recreate caddy

# ★ 差异化：清理 24 小时前的旧镜像
docker image prune -f --filter &quot;until=24h&quot;
</code></pre>

<blockquote>
<p><strong>完整可复制版本</strong>：将上述差异化命令合并进入门篇的 workflow，替换对应的 <code>Deploy to server</code> 步骤脚本，并在 <code>envs</code> 列表中追加所有 NUXT_* 变量即可。</p>
</blockquote>

<h3 id="进阶要点">进阶要点</h3>

<ul>
<li>使用 <code>cache-from</code> 加速构建。</li>
<li>远程脚本中执行数据库迁移（使用 <code>npm install -g</code> 确保有 <code>drizzle-kit</code>，避免依赖缺失）。</li>
<li><code>--force-recreate</code> 确保旧容器被完全替换，解决端口残留问题。</li>
<li>清理 24 小时前的旧镜像，避免磁盘占满。</li>
<li><code>envs</code> 列表中包含了所有需要传递的变量，确保远程 shell 能正确读取。</li>
</ul>

<hr>

<h2 id="第四部分-服务器初始化-生产准备">🧪 第四部分：服务器初始化（生产准备）</h2>

<h3 id="4-1-安装-docker-并配置镜像加速器">4.1 安装 Docker 并配置镜像加速器</h3>

<p>Docker 安装与基础加速器配置请参见<a href="./docker-quickstart-auto-deploy">入门篇 5.1</a>。生产环境额外推荐配置日志切割，在 <code>daemon.json</code> 中追加：</p>

<pre><code class="language-json">{
  &quot;log-driver&quot;: &quot;json-file&quot;,
  &quot;log-opts&quot;: {
    &quot;max-size&quot;: &quot;10m&quot;,
    &quot;max-file&quot;: &quot;3&quot;
  }
}
</code></pre>

<blockquote>
<p>若加速器无效，可使用 ACR 的海外源镜像同步功能或自行推送镜像至私有仓库。</p>
</blockquote>

<h3 id="4-2-创建项目目录并上传文件">4.2 创建项目目录并上传文件</h3>

<pre><code class="language-bash">mkdir -p /var/www/my-app
cd /var/www/my-app
# 将本地的 docker-compose.yml 和 Caddyfile 上传到该目录
# 例如：scp docker-compose.yml Caddyfile root@your-server:/var/www/my-app/
</code></pre>

<h3 id="4-3-首次启动-手动">4.3 首次启动（手动）</h3>

<pre><code class="language-bash"># 手动创建 .env 文件（参照 2.2 的示例）
vim .env
chmod 600 .env

# 启动所有服务
docker compose up -d
</code></pre>

<h3 id="4-4-验证服务">4.4 验证服务</h3>

<pre><code class="language-bash">docker compose ps
curl -I http://localhost:3000  # 应返回 200（应用内部端口）
curl -I https://your-domain.com # 应返回 200（通过 Caddy）
</code></pre>

<hr>

<h2 id="第五部分-深度问题排查手册">🔧 第五部分：深度问题排查手册</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Caddy 容器无法启动，端口 <sup>80</sup>&frasl;<sub>443</sub> 被占用</strong></td>
<td>宿主机有其他 Web 服务（如系统级 Caddy、Nginx）</td>
<td><code>sudo lsof -i :80 -i :443</code> 找到并停止进程；或修改端口映射</td>
</tr>

<tr>
<td><strong>应用无法连接数据库，日志显示 <code>getaddrinfo EAI_AGAIN postgres</code></strong></td>
<td>容器间网络问题 / 数据库服务名错误</td>
<td>确认 <code>app</code> 和 <code>postgres</code> 在同一网络；检查 <code>DATABASE_URL</code> 中的主机名是否为 <code>postgres</code></td>
</tr>

<tr>
<td><strong>数据库迁移失败，提示 <code>drizzle-kit: not found</code></strong></td>
<td>容器内未安装 drizzle-kit</td>
<td>已改为使用 <code>npm install -g drizzle-kit</code>，确保网络通畅；也可在 Dockerfile 中预装</td>
</tr>

<tr>
<td><strong>应用容器不断重启</strong></td>
<td>健康检查失败 / 依赖服务未就绪</td>
<td>查看日志：<code>docker logs my-app --tail 50</code>；检查 <code>depends_on</code> 条件</td>
</tr>

<tr>
<td><strong>部署后网站未更新</strong></td>
<td>容器未重启 / 镜像标签未更新</td>
<td>检查 Actions 日志；手动执行 <code>docker compose pull &amp;&amp; docker compose up -d</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第六部分-日常运维">📈 第六部分：日常运维</h2>

<h3 id="6-1-常用命令">6.1 常用命令</h3>

<p><code>docker compose ps</code>、<code>logs -f</code>、<code>exec</code> 等基础命令请参见<a href="./docker-quickstart-auto-deploy">入门篇 附录</a>。本文特有的运维命令：</p>

<pre><code class="language-bash"># 备份数据库
docker exec my-app-db pg_dump -U postgres myapp &gt; backups/backup_$(date +%Y%m%d).sql

# 恢复数据库
cat backups/backup.sql | docker exec -i my-app-db psql -U postgres -d myapp

# 查看容器健康状态
docker inspect --format='{{.State.Health.Status}}' my-app
</code></pre>

<h3 id="6-2-自动备份-可选">6.2 自动备份（可选）</h3>

<p>添加定时任务（crontab -e）：</p>

<pre><code class="language-bash">0 2 * * * cd /var/www/my-app &amp;&amp; docker exec my-app-db pg_dump -U postgres myapp &gt; backups/backup_$(date +\%Y\%m\%d).sql
</code></pre>

<hr>

<h2 id="总结">🏁 总结</h2>

<p>至此，你已经构建了一套完整的、生产可用的 Docker 自动化部署体系：</p>

<ul>
<li>✅ 多服务容器化编排</li>
<li>✅ 健康检查与依赖控制</li>
<li>✅ 环境变量安全注入</li>
<li>✅ 数据库迁移自动化</li>
<li>✅ 零停机部署</li>
<li>✅ 自动 HTTPS</li>
<li>✅ 数据持久化与备份</li>
<li>✅ 日志切割与镜像清理</li>
</ul>

<p>这套方案可支撑中小型项目稳定运行。未来若需扩展微服务、K8s 等，亦可基于此基础演进。</p>
]]></content:encoded>
      <description><![CDATA[通过容器化技术实现环境一致性，自动构建镜像并分发至私有仓库，用 Docker Compose 编排服务，彻底告别环境依赖。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Docker 极简自动化部署教程（入门篇）]]></title>
      <link>https://moongate.top/docs/docker-quickstart-auto-deploy</link>
      <guid isPermaLink="true">https://moongate.top/docs/docker-quickstart-auto-deploy</guid>
      <pubDate>Mon, 16 Mar 2026 22:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>本教程将带你用最简洁的方式，将你的应用打包成 Docker 镜像，并通过 GitHub Actions 自动部署到服务器。你将学会：</p>

<ul>
<li>编写一个极简的 Dockerfile</li>
<li>使用 docker-compose 管理应用 + 数据库</li>
<li>配置 GitHub Actions 实现 CI/CD</li>
<li>处理环境变量、端口冲突等常见问题</li>
</ul>

<blockquote>
<p><strong>适用人群</strong>：对 Docker 有基础了解，想快速搭建自动化部署的新手。</p>
</blockquote>

<hr>

<h2 id="最终效果">🚀 最终效果</h2>

<p>本地 <code>git push</code> → 自动构建镜像 → 推送到阿里云 ACR → 服务器自动拉取 → 服务重启 → 网站更新。</p>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>Node.js、pnpm、Caddy、GitHub Actions、阿里云 ACR 的版本信息与<a href="./static-site-auto-deploy">静态篇</a>一致。本文额外涉及：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Docker Engine</td>
<td>29.x</td>
<td>容器运行时，支持 BuildKit 和多阶段构建</td>
</tr>

<tr>
<td>Docker Compose</td>
<td>v5</td>
<td>全新 Compose 规范，支持 <code>name</code> 项目和 <code>depends_on</code> 条件</td>
</tr>

<tr>
<td>PostgreSQL</td>
<td>17 (alpine)</td>
<td>轻量级关系型数据库，alpine 版本镜像小巧</td>
</tr>

<tr>
<td>Drizzle ORM</td>
<td>0.30+</td>
<td>TypeScript 原生 ORM，支持迁移和类型安全查询</td>
</tr>

<tr>
<td>PM2</td>
<td>5+</td>
<td>生产级 Node.js 进程管理工具</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意</strong>：请根据你的项目实际需求调整具体版本号。若使用其他技术栈（如 Python、Java 等），请替换对应的运行时版本。</p>
</blockquote>

<hr>

<h2 id="第一步-准备项目">📦 第一步：准备项目</h2>

<h3 id="1-1-项目结构">1.1 项目结构</h3>

<pre><code class="language-text">my-app/
├── .github/workflows/deploy.yml   # GitHub Actions 配置
├── Dockerfile                     # 镜像构建文件
├── docker-compose.yml             # 容器编排文件
├── .env.example                   # 环境变量示例（用于参考，不提交）
└── ... 你的应用代码
</code></pre>

<h3 id="1-2-编写-dockerfile-以-node-js-为例">1.2 编写 Dockerfile（以 Node.js 为例）</h3>

<pre><code class="language-dockerfile"># 构建阶段
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build   # 根据项目调整构建命令，如 npm run generate

# 运行阶段
FROM node:24-alpine
WORKDIR /app
# 根据你的构建输出目录调整以下路径（常见：dist/、.output/、build/）
COPY --from=builder /app/.output ./.output   # 若使用 Nuxt
# 若使用其他框架，请替换为对应的输出目录，例如：
# COPY --from=builder /app/dist ./dist
EXPOSE 3000
# 启动命令也需对应调整，例如：
CMD [&quot;node&quot;, &quot;.output/server/index.mjs&quot;]
# 若使用 Express，可能是 CMD [&quot;node&quot;, &quot;server.js&quot;]
</code></pre>

<blockquote>
<p><strong>提示</strong>：请根据你的项目框架调整构建输出目录和启动命令。</p>
</blockquote>

<hr>

<h2 id="第二步-编写-docker-compose-yml">🛠️ 第二步：编写 docker-compose.yml</h2>

<p>创建一个包含应用和数据库的极简编排文件。<strong>注意</strong>：首次部署前，请确保服务器上已创建 <code>.env</code> 文件（见第五步）。</p>

<pre><code class="language-yaml">name: my-app

services:
  postgres:
    image: postgres:alpine
    container_name: my-app-db
    restart: always
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    image: ${ACR_REGISTRY}/my-app:latest
    container_name: my-app
    restart: always
    ports:
      - &quot;3000:3000&quot; # 直接暴露端口（生产建议用反向代理）
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
</code></pre>

<hr>

<h2 id="第三步-配置-github-secrets">🔐 第三步：配置 GitHub Secrets</h2>

<p><code>SERVER_HOST</code>、<code>SERVER_USER</code>、<code>SSH_PRIVATE_KEY</code> 的配置方法与<a href="./static-site-auto-deploy">静态篇 第二部分</a>一致。本文额外需要：</p>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>ACR_REGISTRY</code></td>
<td>阿里云镜像仓库地址（例如 <code>crpi-xxx.cn-beijing.personal.cr.aliyuncs.com</code>，<strong>不要带 <code>https://</code></strong>）</td>
</tr>

<tr>
<td><code>ACR_USERNAME</code></td>
<td>阿里云账号（邮箱）</td>
</tr>

<tr>
<td><code>ACR_PASSWORD</code></td>
<td>阿里云容器镜像服务固定密码</td>
</tr>

<tr>
<td><code>POSTGRES_DB</code></td>
<td>数据库名</td>
</tr>

<tr>
<td><code>POSTGRES_USER</code></td>
<td>数据库用户</td>
</tr>

<tr>
<td><code>POSTGRES_PASSWORD</code></td>
<td>数据库密码</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>⚠️ 重要</strong>：<code>.env</code> 文件包含敏感信息，<strong>切勿提交到 Git</strong>（已包含在 <code>.gitignore</code> 中）。首次部署前需手动在服务器上创建（见第五步）。</p>
</blockquote>

<hr>

<h2 id="第四步-创建-github-actions-工作流">⚙️ 第四步：创建 GitHub Actions 工作流</h2>

<p>在 <code>.github/workflows/deploy.yml</code> 中写入：</p>

<pre><code class="language-yaml">name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Login to ACR
        uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ACR_REGISTRY }}
          username: ${{ secrets.ACR_USERNAME }}
          password: ${{ secrets.ACR_PASSWORD }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ${{ secrets.ACR_REGISTRY }}/my-app:latest

      - name: Deploy to server
        uses: appleboy/ssh-action@v1.0.0
        env:
          ACR_REGISTRY: ${{ secrets.ACR_REGISTRY }}
          ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
          ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
          POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
          POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
          POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: ACR_REGISTRY, ACR_USERNAME, ACR_PASSWORD, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD
          script: |
            cd /var/www/my-app
            # 写入环境变量（注意不要用单引号，否则变量不会展开）
            cat &gt; .env &lt;&lt; EOF
            POSTGRES_DB=$POSTGRES_DB
            POSTGRES_USER=$POSTGRES_USER
            POSTGRES_PASSWORD=$POSTGRES_PASSWORD
            ACR_REGISTRY=$ACR_REGISTRY
            EOF
            # 登录 ACR
            echo &quot;$ACR_PASSWORD&quot; | docker login &quot;$ACR_REGISTRY&quot; -u &quot;$ACR_USERNAME&quot; --password-stdin
            # 拉取新镜像
            docker compose pull app
            # 重启应用（若端口冲突可添加 --force-recreate）
            docker compose up -d --no-deps --force-recreate app
</code></pre>

<blockquote>
<p><strong>说明</strong>：使用 <code>--force-recreate</code> 确保旧容器被强制重新创建，避免端口冲突。若你的应用需要数据库迁移，请在重启前添加迁移命令，例如 <code>docker compose exec app npm run migrate</code>。</p>
</blockquote>

<hr>

<h2 id="第五步-服务器初始化-只需一次">🖥️ 第五步：服务器初始化（只需一次）</h2>

<h3 id="5-1-安装-docker-并配置镜像加速器">5.1 安装 Docker 并配置镜像加速器</h3>

<pre><code class="language-bash">curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# 重新登录或执行 newgrp docker 使组生效
# 配置阿里云镜像加速器（替换为你的加速地址）
sudo tee /etc/docker/daemon.json &lt;&lt;-'EOF'
{
  &quot;registry-mirrors&quot;: [&quot;https://your-mirror-id.mirror.aliyuncs.com&quot;]
}
EOF
sudo systemctl restart docker
</code></pre>

<h3 id="5-2-创建项目目录并准备环境">5.2 创建项目目录并准备环境</h3>

<pre><code class="language-bash">mkdir -p /var/www/my-app
cd /var/www/my-app
</code></pre>

<h3 id="5-3-首次部署-创建-env-文件并启动服务">5.3 首次部署：创建 <code>.env</code> 文件并启动服务</h3>

<p>由于首次部署时 GitHub Actions 还未运行，需要手动创建 <code>.env</code> 文件并启动一次服务，以便数据库初始化。</p>

<pre><code class="language-bash"># 根据你的实际值创建 .env 文件
cat &gt; .env &lt;&lt; EOF
POSTGRES_DB=myapp
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your-strong-password
ACR_REGISTRY=crpi-xxx.cn-beijing.personal.cr.aliyuncs.com
EOF
</code></pre>

<p>然后将本地的 <code>docker-compose.yml</code> 上传到该目录（例如使用 <code>scp</code>）：</p>

<pre><code class="language-bash"># 在本地执行
scp docker-compose.yml root@your-server:/var/www/my-app/
</code></pre>

<p>首次启动：</p>

<pre><code class="language-bash">docker compose up -d
</code></pre>

<blockquote>
<p><strong>注意</strong>：首次启动后，PostgreSQL 会创建数据卷，后续部署时数据不会丢失。</p>
</blockquote>

<h3 id="5-4-测试服务">5.4 测试服务</h3>

<pre><code class="language-bash">curl http://localhost:3000
# 或通过浏览器访问 http://你的服务器IP:3000
</code></pre>

<hr>

<h2 id="第六步-常见问题极简排查">🔍 第六步：常见问题极简排查</h2>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>Actions 中 SSH 连接失败</td>
<td>私钥格式错误 / 端口未开放</td>
<td>检查私钥是否包含完整换行；确保安全组开放 22 端口</td>
</tr>

<tr>
<td>容器启动失败，端口占用</td>
<td>宿主机有其他进程占用了 3000 端口</td>
<td><code>sudo lsof -i :3000</code> 找到进程并停止</td>
</tr>

<tr>
<td>应用无法连接数据库</td>
<td>环境变量 <code>DATABASE_URL</code> 错误</td>
<td>检查 <code>.env</code> 文件中的连接串，确认主机名为 <code>postgres</code>（服务名）</td>
</tr>

<tr>
<td>镜像拉取慢 / 超时</td>
<td>未配置镜像加速器</td>
<td>按 5.1 配置阿里云加速器并重启 Docker</td>
</tr>

<tr>
<td>容器重启后数据丢失</td>
<td>数据库卷未正确挂载</td>
<td>确认 <code>docker-compose.yml</code> 中有 <code>volumes</code> 定义，且数据卷存在</td>
</tr>

<tr>
<td>首次部署时数据库未初始化</td>
<td><code>.env</code> 文件缺失或变量错误</td>
<td>按 5.3 手动创建 <code>.env</code> 并再次执行 <code>docker compose up -d</code></td>
</tr>

<tr>
<td>应用更新后未生效</td>
<td>镜像未拉取或容器未重启</td>
<td>检查 Actions 日志；手动执行 <code>docker compose pull &amp;&amp; docker compose up -d</code></td>
</tr>
</tbody>
</table>

<hr>

<h2 id="附录-常用运维命令">📖 附录：常用运维命令</h2>

<pre><code class="language-bash"># 查看所有容器状态
docker compose ps

# 查看应用日志
docker compose logs -f app

# 进入应用容器内部调试
docker exec -it my-app sh

# 停止所有服务
docker compose down

# 重新构建并启动（如需重新构建镜像）
docker compose up -d --build
</code></pre>

<hr>

<h2 id="完成">🎉 完成</h2>

<p>现在你已经拥有了一套极简但可工作的自动化部署流水线。每次推送代码到 main 分支，都会自动构建镜像、推送到阿里云、并在服务器上重启应用。</p>

<h3 id="接下来可以探索">接下来可以探索</h3>

<p>添加健康检查、使用 Caddy 反向代理（避免直接暴露端口）、多环境配置、数据库迁移自动化等进阶功能（参见本系列进阶篇）。</p>
]]></content:encoded>
      <description><![CDATA[从零开始，用最简洁的方式将你的应用打包成 Docker 镜像，并通过 GitHub Actions 实现自动构建、推送和服务器部署。适合 Docker 新手快速上手 CI/CD 流水线。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[Docker]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[从零到一：为 Moongate 博客打造一个支持多级引用的评论区]]></title>
      <link>https://moongate.top/docs/nuxt-multi-level-replies</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-multi-level-replies</guid>
      <pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="代码说明">📌 代码说明</h2>

<p>本文所有代码均基于作者的项目环境编写，旨在清晰展示设计思路与核心实现。由于不同项目的配置（如数据库连接、环境变量、文件路径等）可能存在差异，请根据实际情况灵活调整。<strong>直接复制粘贴可能无法运行，理解原理后再动手，才是最高效的学习方式。</strong></p>

<h2 id="1-背景与需求">1. 背景与需求</h2>

<p>在个人技术博客中，评论区是连接作者与读者的重要桥梁。常见的评论区实现要么过于简单（仅支持一级评论），要么依赖第三方服务（如 Disqus），无法自由定制和掌控数据。Moongate 博客需要一个<strong>轻量、可定制、支持技术讨论深度</strong>的评论区，具体要求包括：</p>

<ul>
<li><strong>多级引用</strong>：读者可以针对某条评论或回复进行精确回应，形成对话链。</li>
<li><strong>扁平时间线</strong>：所有评论和回复按时间混合排列，避免视觉上的嵌套混乱。</li>
<li><strong>引用块跳转</strong>：点击引用内容可直接跳转到原评论并高亮，方便追溯上下文。</li>
<li><strong>用户认证</strong>：仅 GitHub 登录用户可发言，保证社区质量。</li>
<li><strong>响应式设计</strong>：在移动端同样有良好体验。</li>
</ul>

<p>技术栈基于 Nuxt v4、Vue 3、Pinia v3、Drizzle ORM 和 PostgreSQL v18，UI 层采用 Nuxt UI 组件库。</p>

<h2 id="2-技术选型与设计思路">2. 技术选型与设计思路</h2>

<h3 id="2-1-数据库设计-多态关联">2.1 数据库设计：多态关联</h3>

<p>传统的评论系统通常设计为“评论表 + 回复表”，回复通过外键指向所属评论。但这种结构无法支持“回复的回复”，即多级引用。为此我们选择了<strong>多态关联</strong>：</p>

<ul>
<li><strong>comments</strong> 表存储独立评论（根节点）。</li>
<li><strong>replies</strong> 表存储所有回复，使用 <code>target_id</code> + <code>target_type</code> 字段指向任意目标（评论或另一条回复）。</li>
</ul>

<p>这种设计灵活性极高，只需增加枚举类型 <code>target_type</code> 即可支持未来的扩展（如指向文章、用户等）。同时，使用 PostgreSQL 枚举确保数据完整性。</p>

<pre><code class="language-sql">CREATE TYPE target_type AS ENUM ('comment', 'reply');

CREATE TABLE replies (
  id SERIAL PRIMARY KEY,
  target_id INTEGER NOT NULL,
  target_type target_type NOT NULL DEFAULT 'comment',
  user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  content TEXT NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_replies_target ON replies(target_id, target_type);
</code></pre>

<h3 id="2-2-扁平时间线-vs-嵌套展示">2.2 扁平时间线 vs 嵌套展示</h3>

<p>传统嵌套回复（如楼中楼）在视觉上会随着深度增加而不断缩进，导致界面复杂，且阅读长对话链时容易迷失。扁平时间线将所有条目（评论和回复）按创建时间统一排序，每条回复通过引用块表明针对的对象。这样既保持了上下文的连贯性，又让界面干净清爽。</p>

<h3 id="2-3-引用块设计">2.3 引用块设计</h3>

<p>回复内容上方显示引用块，格式为 <code>@用户名: 摘要</code>，左侧用细边框线视觉弱化，但保留可点击性。用户点击引用块可跳转到被引用的原内容并高亮，利用现代 CSS 特性 <code>color-mix</code> 实现柔和的高亮效果。</p>

<h2 id="3-后端实现">3. 后端实现</h2>

<h3 id="3-1-数据库-schema-与关系">3.1 数据库 Schema 与关系</h3>

<p>使用 Drizzle ORM 定义表和关系。由于多态关联的特殊性，我们放弃在 ORM 中定义复杂的关系，而是在业务代码中手动组装数据，保证灵活性和可读性。</p>

<details>
<summary>查看完整 schema</summary>

#### `server/db/schema/comments.ts`

<pre><code class="language-ts">import {
  pgTable,
  serial,
  varchar,
  timestamp,
  integer,
  text,
} from &quot;drizzle-orm/pg-core&quot;
import { users } from &quot;./users&quot;

export const comments = pgTable(&quot;comments&quot;, {
  id: serial(&quot;id&quot;).primaryKey(),
  user_id: integer(&quot;user_id&quot;).references(() =&gt; users.id, {
    onDelete: &quot;set null&quot;,
  }),
  content: text(&quot;content&quot;).notNull(),
  permalink: varchar(&quot;permalink&quot;, { length: 255 }).notNull(),
  created_at: timestamp(&quot;created_at&quot;, { withTimezone: true }).defaultNow(),
})

export type CommentSelect = typeof comments.$inferSelect
export type CommentInsert = typeof comments.$inferInsert</code></pre>

#### `server/db/schema/replies.ts`

<pre><code class="language-ts">import {
  pgEnum,
  pgTable,
  serial,
  integer,
  text,
  timestamp,
} from &quot;drizzle-orm/pg-core&quot;
import { users } from &quot;./users&quot;

export const targetTypeEnum = pgEnum(&quot;target_type&quot;, [&quot;comment&quot;, &quot;reply&quot;])

export const replies = pgTable(&quot;replies&quot;, {
  id: serial(&quot;id&quot;).primaryKey(),
  target_id: integer(&quot;target_id&quot;).notNull(),
  target_type: targetTypeEnum(&quot;target_type&quot;).notNull().default(&quot;comment&quot;),
  user_id: integer(&quot;user_id&quot;).references(() =&gt; users.id, {
    onDelete: &quot;set null&quot;,
  }),
  content: text(&quot;content&quot;).notNull(),
  created_at: timestamp(&quot;created_at&quot;, { withTimezone: true }).defaultNow(),
})

export type ReplySelect = typeof replies.$inferSelect
export type ReplyInsert = typeof replies.$inferInsert</code></pre>

#### `server/db/schema/users.ts`

<pre><code class="language-ts">import {
  pgTable,
  serial,
  varchar,
  boolean,
  timestamp,
} from &quot;drizzle-orm/pg-core&quot;

export const users = pgTable(&quot;users&quot;, {
  id: serial(&quot;id&quot;).primaryKey(),
  github_id: varchar(&quot;github_id&quot;, { length: 39 }).notNull().unique(),
  username: varchar(&quot;username&quot;, { length: 100 }).notNull(),
  is_admin: boolean(&quot;is_admin&quot;).default(false),
  created_at: timestamp(&quot;created_at&quot;, { withTimezone: true }).defaultNow(),
})

export type UserSelect = typeof users.$inferSelect
export type UserInsert = typeof users.$inferInsert</code></pre>

</details>

<details>
<summary>查看完整关系表</summary>

<pre><code class="language-ts">import { relations } from &quot;drizzle-orm&quot;
import { users, comments, replies } from &quot;./index&quot;

// comments 表的关系
export const commentsRelations = relations(comments, ({ one, many }) =&gt; ({
  user: one(users, {
    fields: [comments.user_id],
    references: [users.id],
  }),
  // 指向此评论的回复（通过 target_id 和 target_type 筛选）
  // 注意：这只是一个定义，实际查询时需在 where 中添加 target_type = &apos;comment&apos;
  repliesFrom: many(replies, {
    relationName: &quot;commentTarget&quot;,
  }),
}))

// users 表的关系（不变）
export const usersRelations = relations(users, ({ many }) =&gt; ({
  comments: many(comments),
  replies: many(replies),
}))

// replies 表的关系
export const repliesRelations = relations(replies, ({ one }) =&gt; ({
  user: one(users, {
    fields: [replies.user_id],
    references: [users.id],
  }),
  // 当 target_type = &apos;comment&apos; 时，指向被引用的评论
  targetComment: one(comments, {
    fields: [replies.target_id],
    references: [comments.id],
    relationName: &quot;commentTarget&quot;, // 与 commentsRelations 中的 repliesFrom 对应
  }),
  // 当 target_type = &apos;reply&apos; 时，指向被引用的回复
  targetReply: one(replies, {
    fields: [replies.target_id],
    references: [replies.id],
    relationName: &quot;replyTarget&quot;,
  }),
}))</code></pre>

</details>

<h3 id="3-2-获取时间线接口">3.2 获取时间线接口</h3>

<p>该接口需要返回当前文章的所有评论和回复，并为每条回复附上被引用内容的摘要（<code>reply_to</code>）。我们采用两步查询：先获取所有评论，再获取所有回复，然后在内存中组装并排序。</p>

<h4 id="server-api-comment-timeline-get-ts"><code>server/api/comment/timeline.get.ts</code></h4>

<details>
<summary>完整的获取时间线接口代码</summary>

<pre><code class="language-ts">import { eq, sql } from &quot;drizzle-orm&quot;
import { useDB } from &quot;~~/server/db&quot;
import { comments, replies, users } from &quot;~~/server/db/schema&quot;

export default defineEventHandler(async (event) =&gt; {
  const { permalink } = getQuery(event)
  if (!permalink)
    throw createError({ status: 400, statusText: &quot;缺少 permalink&quot; })

  const db = useDB()

  // 获取所有评论
  const commentsData = await db
    .select({
      id: comments.id,
      content: comments.content,
      user_id: comments.user_id,
      created_at: comments.created_at,
      user: { username: users.username, is_admin: users.is_admin },
    })
    .from(comments)
    .leftJoin(users, eq(comments.user_id, users.id))
    .where(eq(comments.permalink, permalink as string))
    .orderBy(comments.created_at)

  // 构建评论映射，供后续引用摘要使用
  const commentMap = new Map(
    commentsData.map((c) =&gt; [
      c.id,
      { content: c.content, username: c.user?.username },
    ]),
  )

  // 获取所有回复（限制属于当前文章）
  const repliesData = await db
    .select({
      id: replies.id,
      content: replies.content,
      user_id: replies.user_id,
      created_at: replies.created_at,
      target_id: replies.target_id,
      target_type: replies.target_type,
      user: { username: users.username, is_admin: users.is_admin },
    })
    .from(replies)
    .leftJoin(users, eq(replies.user_id, users.id))
    .where(
      sql`${replies.target_id} IN (SELECT id FROM comments WHERE permalink = ${permalink})
                OR ${replies.target_id} IN (SELECT id FROM replies r2 WHERE r2.target_id IN (SELECT id FROM comments WHERE permalink = ${permalink}))`,
    )
    .orderBy(replies.created_at)

  // 构建回复映射
  const replyMap = new Map(
    repliesData.map((r) =&gt; [
      r.id,
      { content: r.content, username: r.user?.username },
    ]),
  )

  // 格式化评论
  const formattedComments = commentsData.map((c) =&gt; ({
    id: c.id,
    type: &quot;comment&quot; as const,
    content: c.content,
    user: c.user,
    created_at: c.created_at,
  }))

  // 格式化回复并添加引用摘要
  const formattedReplies = repliesData.map((r) =&gt; {
    const target =
      r.target_type === &quot;comment&quot;
        ? commentMap.get(r.target_id)
        : replyMap.get(r.target_id)

    return {
      id: r.id,
      type: &quot;reply&quot; as const,
      content: r.content,
      user: r.user,
      created_at: r.created_at,
      target_id: r.target_id,
      target_type: r.target_type,
      reply_to: target
        ? {
            id: r.target_id,
            type: r.target_type,
            username: target.username,
            excerpt:
              target.content.substring(0, 100) +
              (target.content.length &gt; 100 ? &quot;…&quot; : &quot;&quot;),
          }
        : null,
    }
  })

  // 合并并按时间排序
  const timeline = [...formattedComments, ...formattedReplies].sort(
    (a, b) =&gt;
      new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
  )

  return { success: true, data: timeline }
})</code></pre>

</details>

<h3 id="3-3-提交评论接口">3.3 提交评论接口</h3>

<p>简单地将用户输入插入 <code>comments</code> 表，返回新评论数据：</p>

<h4 id="server-api-comment-post-ts"><code>server/api/comment/post.ts</code></h4>

<pre><code class="language-ts">import { useDB } from &quot;~~/server/db&quot;
import { comments } from &quot;~~/server/db/schema&quot;

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event)
  const session = await getUserSession(event)

  if (!session.user?.id)
    throw createError({ status: 401, statusText: &quot;请先登录&quot; })
  if (!body.content?.trim() || !body.permalink)
    throw createError({ status: 400, statusText: &quot;参数错误&quot; })

  const db = useDB()
  const [newComment] = await db
    .insert(comments)
    .values({
      user_id: session.user.id,
      content: body.content.trim(),
      permalink: body.permalink,
    })
    .returning()

  event.node.res.statusCode = 201
  return { success: true, data: newComment }
})
</code></pre>

<blockquote>
<p>💡 更完整的安全加固（用户校验、敏感词过滤、文档归属验证、限流）见本系列第 3 篇 <a href="./nuxt-comment-security">《为评论区添加内容过滤与安全防护》</a>。</p>
</blockquote>

<h3 id="3-4-提交回复接口">3.4 提交回复接口</h3>

<p>需要验证目标是否存在，并处理多态引用。注意使用 <code>createError</code> 抛出规范错误。</p>

<h4 id="server-api-reply-post-ts"><code>server/api/reply/post.ts</code></h4>

<details>
<summary>查看完整的回复接口代码</summary>

<pre><code class="language-ts">import { eq } from &quot;drizzle-orm&quot;
import { useDB } from &quot;~~/server/db&quot;
import { replies, users, comments } from &quot;~~/server/db/schema&quot;

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event)
  const session = await getUserSession(event)

  // 参数校验
  if (
    !body.target_id ||
    ![&quot;comment&quot;, &quot;reply&quot;].includes(body.target_type) ||
    !body.content?.trim()
  ) {
    throw createError({ status: 400, statusText: &quot;参数错误&quot; })
  }
  if (!session.user?.id)
    throw createError({ status: 401, statusText: &quot;请先登录&quot; })

  const db = useDB()

  // 验证用户存在
  const user = await db.query.users.findFirst({
    where: eq(users.id, session.user.id),
  })
  if (!user) {
    await clearUserSession(event)
    throw createError({ status: 401, statusText: &quot;用户不存在&quot; })
  }

  // 验证目标存在
  if (body.target_type === &quot;comment&quot;) {
    const comment = await db
      .select()
      .from(comments)
      .where(eq(comments.id, body.target_id))
      .limit(1)
    if (!comment.length)
      throw createError({ status: 404, statusText: &quot;评论不存在&quot; })
  } else {
    const reply = await db
      .select()
      .from(replies)
      .where(eq(replies.id, body.target_id))
      .limit(1)
    if (!reply.length)
      throw createError({ status: 404, statusText: &quot;回复不存在&quot; })
  }

  // 插入回复
  try {
    const [newReply] = await db
      .insert(replies)
      .values({
        user_id: user.id,
        target_id: body.target_id,
        target_type: body.target_type,
        content: body.content.trim(),
      })
      .returning()

    event.node.res.statusCode = 201
    return { success: true, data: newReply }
  } catch (error) {
    console.error(error)
    throw createError({ status: 500, statusText: &quot;服务器内部错误&quot; })
  }
})</code></pre>

</details>

<h2 id="4-前端状态管理">4. 前端状态管理</h2>

<p>使用 Pinia 管理评论相关状态，包括当前输入内容、评论列表、加载状态等。</p>

<h3 id="stores-comment-ts"><code>stores/comment.ts</code></h3>

<details>
<summary>查看完整的pinia代码</summary>

<pre><code class="language-ts">import { defineStore } from &quot;pinia&quot;

export const useCommentStore = defineStore(&quot;comment&quot;, () =&gt; {
  const comment = ref(&quot;&quot;) // 当前输入的评论内容
  const permalink = ref(&quot;&quot;) // 当前文章标识
  const commentList = ref&lt;any[]&gt;([]) // 扁平时间线数据
  const loading = ref(false) // 获取列表加载状态
  const submitting = ref(false) // 提交评论/回复中

  const getCommentList = async (newPermalink?: string) =&gt; {
    if (newPermalink) permalink.value = newPermalink
    if (!permalink.value) return
    loading.value = true
    try {
      const { data } = await $fetch(&quot;/api/comment/timeline&quot;, {
        query: { permalink: permalink.value },
      })
      commentList.value = data || []
    } catch (error) {
      console.error(&quot;获取评论失败&quot;, error)
      commentList.value = []
    } finally {
      loading.value = false
    }
  }

  const submitComment = async () =&gt; {
    if (!comment.value.trim() || submitting.value) return false
    submitting.value = true
    try {
      const response = await $fetch(&quot;/api/comment/post&quot;, {
        method: &quot;POST&quot;,
        body: { content: comment.value, permalink: permalink.value },
      })
      if (response.success) {
        comment.value = &quot;&quot;
        await getCommentList()
        return true
      }
      return false
    } catch (error) {
      console.error(error)
      return false
    } finally {
      submitting.value = false
    }
  }

  const submitReply = async (
    targetId: number,
    targetType: string,
    content: string,
  ) =&gt; {
    if (!content.trim() || submitting.value) return false
    submitting.value = true
    try {
      const response = await $fetch(&quot;/api/reply/post&quot;, {
        method: &quot;POST&quot;,
        body: { target_id: targetId, target_type: targetType, content },
      })
      if (response.success) {
        await getCommentList()
        return true
      }
      return false
    } catch (error) {
      console.error(error)
      return false
    } finally {
      submitting.value = false
    }
  }

  return {
    comment,
    permalink,
    commentList,
    loading,
    submitting,
    getCommentList,
    submitComment,
    submitReply,
  }
})</code></pre>

</details>

<h2 id="5-前端组件实现">5. 前端组件实现</h2>

<h3 id="5-1-评论区容器组件">5.1 评论区容器组件</h3>

<h4 id="components-docs-commentsection-vue"><code>components/docs/CommentSection.vue</code></h4>

<details>
<summary>查看完整组件代码</summary>

```vue
<template>
  <details ref="containerRef" @toggle="onDetailsToggle">
    <summary class="text-center">{{ t("comment.section") }}</summary>

    <ClientOnly>
      <DocsCommentInputPreview
        v-model="commentStore.comment"
        :debounce-time="500"
        :permalink="prop.permalink"
        storage-type="none"
      />
    </ClientOnly>

    <div class="flex justify-end mb-8">
      <ClientOnly v-if="loggedIn">
        <UButton
          :disabled="!commentStore.comment.trim() || commentStore.submitting"
          :loading="commentStore.submitting"
          :label="t('comment.actions.send')"
          size="lg"
          @click="commentStore.submitComment()"
        />
      </ClientOnly>
      <div v-else class="flex items-center gap-2">
        <p>{{ t("comment.status.login_to_comment") }}</p>
        <SharedLogin />
      </div>
    </div>

    <div class="mt-4 min-h-50">
      <DocsCommentList v-if="commentStore.commentList.length" />
      <div v-else class="text-center">{{ t("comment.status.noComments") }}</div>
    </div>
  </details>
</template>

<script setup>
import { useCommentStore } from "~/stores/comment"
const commentStore = useCommentStore()
const { t } = useI18n()
const { containerRef, onDetailsToggle } = useDetailsScroll()
const { loggedIn } = useUserSession()

const prop = defineProps({ permalink: { type: String, required: true } })

watch(
  () => prop.permalink,
  (newPermalink) => {
    commentStore.getCommentList(newPermalink)
  },
  { immediate: true },
)
</script>
```

</details>

<h3 id="5-2-评论列表组件">5.2 评论列表组件</h3>

<h4 id="components-docs-commentlist-vue"><code>components/docs/CommentList.vue</code></h4>

<details>
<summary>查看完整组件代码</summary>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;max-h-150 overflow-y-auto mt-4 space-y-4&quot;&gt;
    &lt;div
      v-for=&quot;item in commentStore.commentList&quot;
      :key=&quot;`${item.type}-${item.id}`&quot;
      :id=&quot;`${item.type}-${item.id}`&quot;
      class=&quot;group relative py-6 border-b border-ui-border/30 hover:bg-ui-bg-elevated/50 transition-colors&quot;
    &gt;
      &lt;!-- 引用块（仅回复） --&gt;
      &lt;div
        v-if=&quot;item.type === &apos;reply&apos; &amp;&amp; item.reply_to&quot;
        class=&quot;mb-2 pl-3 text-sm text-ui-text-muted/80 border-l-2 border-ui-border/40 hover:border-ui-primary/40 transition-colors cursor-pointer&quot;
        @click=&quot;scrollToElement(item.reply_to.id, item.reply_to.type)&quot;
      &gt;
        &lt;span class=&quot;font-medium&quot;&gt;@{{ item.reply_to.username }}&lt;/span&gt;
        &lt;span class=&quot;italic ml-1&quot;&gt;{{ item.reply_to.excerpt }}&lt;/span&gt;
      &lt;/div&gt;

      &lt;!-- 作者信息 --&gt;
      &lt;div class=&quot;flex items-center gap-2 mb-1 text-xs&quot;&gt;
        &lt;span class=&quot;font-mono font-bold text-ui-text&quot;&gt;{{
          item.user?.username
        }}&lt;/span&gt;
        &lt;span
          v-if=&quot;item.user?.is_admin&quot;
          class=&quot;text-[9px] px-1 bg-ui-primary/10 text-ui-primary border border-ui-primary/20&quot;
        &gt;
          {{ t(&quot;comment.badge.admin&quot;) }}
        &lt;/span&gt;
        &lt;span class=&quot;text-ui-text-muted/60 text-[10px]&quot;&gt;
          {{ dayjs(item.created_at).format(&quot;MM-DD HH:mm&quot;) }}
        &lt;/span&gt;
      &lt;/div&gt;

      &lt;!-- 评论内容 --&gt;
      &lt;div
        class=&quot;text-ui-text/90 text-sm leading-relaxed break-words max-w-3xl&quot;
      &gt;
        &lt;DocsMarkdownRenderer :content=&quot;item.content&quot; /&gt;
      &lt;/div&gt;

      &lt;!-- 回复按钮 --&gt;
      &lt;div class=&quot;flex justify-end mt-2&quot;&gt;
        &lt;button
          class=&quot;text-xs text-ui-text-muted/70 hover:text-ui-primary transition-colors cursor-pointer&quot;
          @click=&quot;toggleReply(item.id, item.type)&quot;
        &gt;
          {{ t(&quot;comment.actions.reply&quot;) }}
        &lt;/button&gt;
      &lt;/div&gt;

      &lt;!-- 回复输入框 --&gt;
      &lt;div
        v-if=&quot;replyingTo?.id === item.id &amp;&amp; replyingTo?.type === item.type&quot;
        class=&quot;mt-3 pt-3 border-t border-ui-border/20&quot;
      &gt;
        &lt;DocsCommentInputPreview
          v-model=&quot;reply&quot;
          :permalink=&quot;commentStore.permalink&quot;
        /&gt;
        &lt;div class=&quot;flex justify-end gap-2 mt-2&quot;&gt;
          &lt;UButton size=&quot;sm&quot; variant=&quot;ghost&quot; @click=&quot;cancelReply&quot;&gt;
            {{ t(&quot;common.cancel&quot;) }}
          &lt;/UButton&gt;
          &lt;UButton
            size=&quot;sm&quot;
            :disabled=&quot;!reply.trim() || commentStore.submitting&quot;
            :loading=&quot;commentStore.submitting&quot;
            @click=&quot;handleReply&quot;
          &gt;
            {{ t(&quot;comment.actions.send&quot;) }}
          &lt;/UButton&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
import dayjs from &apos;dayjs&apos;;
import { useCommentStore } from &apos;~/stores/comment&apos;;

const commentStore = useCommentStore();
const { user, loggedIn } = useUserSession();
const { t } = useI18n();

const replyingTo = ref&lt;{ id: number; type: string } | null&gt;(null);
const reply = ref(&apos;&apos;);

const toggleReply = (id: number, type: string) =&gt; {
  if (replyingTo.value?.id === id &amp;&amp; replyingTo.value?.type === type) {
    replyingTo.value = null;
  } else {
    replyingTo.value = { id, type };
  }
  reply.value = &apos;&apos;;
};

const cancelReply = () =&gt; {
  replyingTo.value = null;
  reply.value = &apos;&apos;;
};

const handleReply = async () =&gt; {
  if (!replyingTo.value) return;
  const success = await commentStore.submitReply(
    replyingTo.value.id,
    replyingTo.value.type,
    reply.value
  );
  if (success) {
    cancelReply();
  }
};

const scrollToElement = (id: number, type: string) =&gt; {
  const el = document.getElementById(`${type}-${id}`);
  if (el) {
    el.scrollIntoView({ behavior: &apos;smooth&apos;, block: &apos;center&apos; });
    el.classList.add(&apos;highlight-flash&apos;);
    setTimeout(() =&gt; el.classList.remove(&apos;highlight-flash&apos;), 1000);
  }
};
&lt;/script&gt;

&lt;style scoped&gt;
.highlight-flash {
  background-color: color-mix(in srgb, var(--ui-primary), transparent 90%);
  transition: background-color 0.3s ease;
}
&lt;/style&gt;</code></pre>

</details>

<h3 id="5-3-输入预览组件">5.3 输入预览组件</h3>

<p><strong><code>components/docs/CommentInputPreview.vue</code></strong> 实现了带防抖的 Markdown 输入和预览。</p>

<details>
<summary>查看完整组件代码</summary>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 mb-6&quot;&gt;
    &lt;!-- 左侧预览 --&gt;
    &lt;div class=&quot;bg-ui-bg-elevated&quot;&gt;
      &lt;div class=&quot;text-xs font-mono text-ui-text-muted mb-2 tracking-wider&quot;&gt;
        // {{ t(&quot;comment.input.preview&quot;) }}
      &lt;/div&gt;
      &lt;DocsMarkdownRenderer
        class=&quot;text-ui-text/90 text-base leading-relaxed&quot;
        :content=&quot;localValue&quot;
      /&gt;
    &lt;/div&gt;

    &lt;!-- 右侧输入 --&gt;
    &lt;div class=&quot;bg-ui-bg&quot;&gt;
      &lt;div class=&quot;text-xs font-mono text-ui-text-muted mb-2 tracking-wider&quot;&gt;
        // {{ t(&quot;comment.input.input&quot;) }}
      &lt;/div&gt;
      &lt;UTextarea
        :model-value=&quot;localValue&quot;
        autoresize
        :rows=&quot;5&quot;
        variant=&quot;none&quot;
        :placeholder=&quot;t(&apos;comment.input.placeholder&apos;)&quot;
        class=&quot;w-full bg-transparent border-0 focus:ring-0 p-0 text-ui-text placeholder:text-ui-text-muted/50 font-mono text-sm&quot;
        @update:model-value=&quot;(value) =&gt; handleInput(value)&quot;
      /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script lang=&quot;ts&quot; setup&gt;
import { useDebounceFn } from &quot;@vueuse/core&quot;
const { t } = useI18n()

const props = defineProps({
  modelValue: { type: String, default: &quot;&quot; }, // v-model 绑定的值
  debounceTime: { type: Number, default: 300 }, // 防抖延迟（毫秒），默认 300ms
  permalink: { type: String, required: true }, // 用于构建存储 key
  storageType: {
    type: String,
    default: &quot;none&quot;,
    validator: (val: string) =&gt; [&quot;session&quot;, &quot;local&quot;, &quot;none&quot;].includes(val),
  },
})

const emit = defineEmits([&quot;update:modelValue&quot;])

// 创建一个 ref 来存储本地输入值
const localValue = ref(props.modelValue)

// 监听父组件 prop 变化，同步到本地
watch(
  () =&gt; props.modelValue,
  (newVal) =&gt; {
    localValue.value = newVal
  },
)

// 用防抖函数包装 emit
const debouncedEmit = useDebounceFn((value: string) =&gt; {
  emit(&quot;update:modelValue&quot;, value)
}, props.debounceTime)

// 当输入框的文本改变时
const handleInput = (value: string) =&gt; {
  localValue.value = value // 立即更新预览
  debouncedEmit(value) // 防抖更新父组件
}
&lt;/script&gt;</code></pre>

</details>

<h2 id="6-交互细节打磨">6. 交互细节打磨</h2>

<h3 id="6-1-防抖输入">6.1 防抖输入</h3>

<p>在 <code>CommentInputPreview</code> 中使用 <code>useDebounceFn</code> 实现用户停止输入 300ms 后才更新父组件，避免频繁请求。</p>

<h3 id="6-2-点击引用跳转并高亮">6.2 点击引用跳转并高亮</h3>

<p>如上代码所示，点击引用块时调用 <code>scrollToElement</code>，利用 <code>scrollIntoView</code> 平滑滚动到目标元素，并添加一个临时 CSS 类实现高亮。高亮采用 <code>color-mix</code> 生成半透明背景色，简洁现代。</p>

<h3 id="6-3-回复框的开关管理">6.3 回复框的开关管理</h3>

<p>每个条目独立控制回复框的展开/关闭，使用 <code>replyingTo</code> 记录目标，确保同时只能打开一个回复框，防止界面混乱。</p>

<h3 id="6-4-提交后自动刷新">6.4 提交后自动刷新</h3>

<p>提交评论或回复成功后，调用 <code>getCommentList</code> 刷新整个列表，确保数据一致性。</p>

<h2 id="7-总结与展望">7. 总结与展望</h2>

<p>至此，博客拥有了一套功能完备、体验优雅的评论区系统。它不仅支持多级引用、扁平时间线、引用跳转高亮，还具备良好的响应式设计和用户体验。</p>
]]></content:encoded>
      <description><![CDATA[介绍了 Moongate 博客的评论区设计和实现，包括多级引用、扁平时间线、引用块跳转、用户认证、响应式设计等。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Security]]></category>
      <dc:relation><![CDATA[series:comment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 集成 RSS 服务完全指南：从模块到手写的优雅之路]]></title>
      <link>https://moongate.top/docs/nuxt-rss-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-rss-guide</guid>
      <pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="为什么需要-rss">为什么需要 RSS？</h2>

<p>在算法推荐泛滥的今天，RSS 依然是最纯粹的内容订阅方式。它让用户真正掌控自己获取信息的渠道，不受平台算法的干扰。为你的 Nuxt 博客添加 RSS 服务，不仅能提升用户体验，更是对开放互联网精神的致敬。</p>

<p>本文将带你从零开始，在 Nuxt 4 中实现一个<strong>完全可控、生产可用</strong>的 RSS 服务。我们将绕过第三方模块的潜在陷阱，亲手构建属于自己的 RSS 生成器。</p>

<blockquote>
<p>💡 <strong>前置要求</strong>：本文假设你已经熟悉 Nuxt 4 和 @nuxt/content v3 的基本使用，能够独立创建项目并配置 content 模块。如果你还不熟悉这些，建议先查阅官方文档。</p>
</blockquote>

<hr>

<h2 id="两种方案的对比">两种方案的对比</h2>

<h3 id="方案一-使用第三方模块-如-nuxt-feedme">方案一：使用第三方模块（如 <code>nuxt-feedme</code>）</h3>

<h4 id="看似简单">看似简单</h4>

<pre><code class="language-ts">export default defineNuxtConfig({
  modules: [&quot;nuxt-feedme&quot;],
  feedme: {
    feeds: {
      &quot;/feed.xml&quot;: { type: &quot;rss2&quot; },
    },
  },
})
</code></pre>

<h4 id="实际可能遇到的坑">实际可能遇到的坑</h4>

<ul>
<li>❌ 生产环境 API 404（模块试图调用不存在的开发接口）</li>
<li>❌ 文档老旧，与实际版本脱节</li>
<li>❌ 配置复杂，黑盒调试困难</li>
<li>❌ 钩子机制学习成本高</li>
<li>❌ 依赖更新可能导致兼容性问题</li>
</ul>

<h3 id="方案二-手写-rss-本文推荐">方案二：手写 RSS（本文推荐）</h3>

<h4 id="核心优势">核心优势</h4>

<ul>
<li>✅ 完全可控，每一行代码都了然于心</li>
<li>✅ 无依赖，零兼容问题</li>
<li>✅ 调试简单，哪里错看哪里</li>
<li>✅ 性能极致，可按需优化</li>
<li>✅ 代码量少，维护成本极低</li>
</ul>

<hr>

<h2 id="rss-的本质-一句话说透">RSS 的本质：一句话说透</h2>

<pre><code class="language-ts">RSS = 获取数据 + 拼接 XML（或 JSON）
</code></pre>

<p>仅此而已。理解了这一点，你就掌握了 RSS 的全部奥秘。</p>

<hr>

<h2 id="完整实现步骤">完整实现步骤</h2>

<h3 id="1-环境配置">1. 环境配置</h3>

<p>在 <code>nuxt.config.ts</code> 中配置好运行时变量：</p>

<pre><code class="language-ts">export default defineNuxtConfig({
  modules: [&quot;@nuxt/content&quot;],
  runtimeConfig: {
    public: {
      siteUrl: process.env.SITE_URL || &quot;https://yourdomain.com&quot;,
      siteName: &quot;你的博客名称&quot;,
      siteDescription: &quot;博客描述&quot;,
    },
  },
})
</code></pre>

<h3 id="2-创建-minimarktree-转-html-工具函数">2. 创建 MinimarkTree 转 HTML 工具函数</h3>

<p>Nuxt Content v3 返回的 <code>doc.body.value</code> 是结构化的 MinimarkTree，需要转换为 HTML。函数会完整保留标签属性，确保图片、链接等元素正常显示。</p>

<pre><code class="language-ts">// utils/minimarkToHtml.ts

/**
 * 将 Nuxt Content v3 的 MinimarkTree 转换为 HTML 字符串
 * @param node - 文档 body 的 value 节点 (doc.body.value)
 * @returns HTML 字符串
 */
export function minimarkToHtml(node: any): string {
  if (!node) return &quot;&quot;

  // 处理根节点
  if (node.type === &quot;minimark&quot; &amp;&amp; Array.isArray(node.value)) {
    return node.value.map(minimarkToHtml).join(&quot;&quot;)
  }

  // 文本节点
  if (typeof node === &quot;string&quot;) return node

  // 数组节点
  if (Array.isArray(node)) {
    return node.map(minimarkToHtml).join(&quot;&quot;)
  }

  if (node &amp;&amp; typeof node === &quot;object&quot;) {
    // 元素节点（带标签）
    if (node.tag) {
      // 生成属性字符串
      const attrs = node.props
        ? &quot; &quot; +
          Object.entries(node.props)
            .map(
              ([key, val]) =&gt; `${key}=&quot;${String(val).replace(/&quot;/g, &quot;&amp;quot;&quot;)}&quot;`,
            )
            .join(&quot; &quot;)
        : &quot;&quot;

      const children = (node.children || []).map(minimarkToHtml).join(&quot;&quot;)

      // 自闭合标签
      if ([&quot;img&quot;, &quot;br&quot;, &quot;hr&quot;, &quot;input&quot;].includes(node.tag)) {
        return `&lt;${node.tag}${attrs} /&gt;`
      }
      return `&lt;${node.tag}${attrs}&gt;${children}&lt;/${node.tag}&gt;`
    }
  }

  return &quot;&quot;
}
</code></pre>

<h3 id="3-创建-rss-2-0-生成器">3. 创建 RSS 2.0 生成器</h3>

<pre><code class="language-ts">// server/routes/feed.xml.ts
import { minimarkToHtml } from &quot;~/utils/minimarkToHtml&quot;

export default defineEventHandler(async (event) =&gt; {
  const { siteName, siteDescription, siteUrl } = useRuntimeConfig().public

  const docs = await queryCollection(event, &quot;docs&quot;).order(&quot;date&quot;, &quot;DESC&quot;).all()

  let rss = `&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;rss version=&quot;2.0&quot; xmlns:content=&quot;http://purl.org/rss/1.0/modules/content/&quot;&gt;
  &lt;channel&gt;
    &lt;title&gt;${siteName}&lt;/title&gt;
    &lt;link&gt;${siteUrl}&lt;/link&gt;
    &lt;description&gt;${siteDescription}&lt;/description&gt;
    &lt;language&gt;zh-CN&lt;/language&gt;
    &lt;lastBuildDate&gt;${new Date().toUTCString()}&lt;/lastBuildDate&gt;
`

  for (const doc of docs) {
    let fullContent = &quot;&quot;
    if (doc.body?.value) {
      try {
        fullContent = minimarkToHtml(doc.body.value)
      } catch (e) {
        console.error(&quot;转换失败:&quot;, e)
        fullContent = doc.description || &quot;&quot;
      }
    }

    const link = `${siteUrl}${doc.path}`
    const date = new Date(doc.date).toUTCString()

    rss += `
    &lt;item&gt;
      &lt;title&gt;&lt;![CDATA[${doc.title}]]&gt;&lt;/title&gt;
      &lt;link&gt;${link}&lt;/link&gt;
      &lt;guid isPermaLink=&quot;true&quot;&gt;${link}&lt;/guid&gt;
      &lt;pubDate&gt;${date}&lt;/pubDate&gt;
      &lt;description&gt;&lt;![CDATA[${doc.description || &quot;&quot;}]]&gt;&lt;/description&gt;
      &lt;content:encoded&gt;&lt;![CDATA[${fullContent}]]&gt;&lt;/content:encoded&gt;
    &lt;/item&gt;
`
  }

  rss += `
  &lt;/channel&gt;
&lt;/rss&gt;`

  setResponseHeader(event, &quot;content-type&quot;, &quot;application/xml; charset=utf-8&quot;)
  return rss
})
</code></pre>

<h3 id="4-创建-atom-1-0-生成器">4. 创建 Atom 1.0 生成器</h3>

<p>Atom 是另一种 XML 格式的订阅标准，结构更规范：</p>

<pre><code class="language-ts">// server/routes/feed.atom.ts
import { minimarkToHtml } from &quot;~/utils/minimarkToHtml&quot;

export default defineEventHandler(async (event) =&gt; {
  const { siteName, siteDescription, siteUrl } = useRuntimeConfig().public

  const docs = await queryCollection(event, &quot;docs&quot;).order(&quot;date&quot;, &quot;DESC&quot;).all()

  const updated = docs[0]?.date
    ? new Date(docs[0].date).toISOString()
    : new Date().toISOString()

  let atom = `&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;
&lt;feed xmlns=&quot;http://www.w3.org/2005/Atom&quot;&gt;
  &lt;title&gt;${siteName}&lt;/title&gt;
  &lt;subtitle&gt;${siteDescription}&lt;/subtitle&gt;
  &lt;link href=&quot;${siteUrl}/feed.atom&quot; rel=&quot;self&quot;/&gt;
  &lt;link href=&quot;${siteUrl}&quot; rel=&quot;alternate&quot;/&gt;
  &lt;id&gt;${siteUrl}&lt;/id&gt;
  &lt;updated&gt;${updated}&lt;/updated&gt;
  &lt;author&gt;
    &lt;name&gt;MoonGate&lt;/name&gt;
  &lt;/author&gt;
`

  for (const doc of docs) {
    let content = &quot;&quot;
    if (doc.body?.value) {
      try {
        content = minimarkToHtml(doc.body.value)
      } catch (e) {
        console.error(&quot;转换失败:&quot;, e)
        content = doc.description || &quot;&quot;
      }
    }

    const link = `${siteUrl}${doc.path}`
    const published = new Date(doc.date).toISOString()

    atom += `
  &lt;entry&gt;
    &lt;title&gt;${doc.title}&lt;/title&gt;
    &lt;link href=&quot;${link}&quot;/&gt;
    &lt;id&gt;${link}&lt;/id&gt;
    &lt;published&gt;${published}&lt;/published&gt;
    &lt;updated&gt;${published}&lt;/updated&gt;
    &lt;summary&gt;${doc.description || &quot;&quot;}&lt;/summary&gt;
    &lt;content type=&quot;html&quot;&gt;&lt;![CDATA[${content}]]&gt;&lt;/content&gt;
  &lt;/entry&gt;
`
  }

  atom += `\n&lt;/feed&gt;`

  setResponseHeader(
    event,
    &quot;content-type&quot;,
    &quot;application/atom+xml; charset=utf-8&quot;,
  )
  return atom
})
</code></pre>

<h3 id="5-创建-json-feed-1-1-生成器">5. 创建 JSON Feed 1.1 生成器</h3>

<p>JSON Feed 是现代化的订阅格式，结构清晰：</p>

<pre><code class="language-ts">// server/routes/feed.json.ts
import { minimarkToHtml } from &quot;~/utils/minimarkToHtml&quot;

export default defineEventHandler(async (event) =&gt; {
  const { siteUrl } = useRuntimeConfig().public

  const docs = await queryCollection(event, &quot;docs&quot;).order(&quot;date&quot;, &quot;DESC&quot;).all()

  const feed = {
    version: &quot;https://jsonfeed.org/version/1.1&quot;,
    title: &quot;MoonGate&quot;,
    home_page_url: siteUrl,
    feed_url: `${siteUrl}/feed.json`,
    description: &quot;Where Moon Meets Code&quot;,
    language: &quot;zh-CN&quot;,
    authors: [
      {
        name: &quot;MoonGate&quot;,
        url: siteUrl,
      },
    ],
    items: await Promise.all(
      docs.map(async (doc) =&gt; {
        let contentHtml = &quot;&quot;
        if (doc.body?.value) {
          try {
            contentHtml = minimarkToHtml(doc.body.value)
          } catch (e) {
            console.error(&quot;转换失败:&quot;, e)
            contentHtml = doc.description || &quot;&quot;
          }
        }

        return {
          id: `${siteUrl}${doc.path}`,
          url: `${siteUrl}${doc.path}`,
          title: doc.title,
          content_html: contentHtml,
          summary: doc.description || &quot;&quot;,
          date_published: new Date(doc.date).toISOString(),
          language: &quot;zh-CN&quot;,
          tags: doc.tags || [],
        }
      }),
    ),
  }

  setResponseHeader(
    event,
    &quot;content-type&quot;,
    &quot;application/feed+json; charset=utf-8&quot;,
  )
  return feed
})
</code></pre>

<h3 id="6-添加缓存优化-可选">6. 添加缓存优化（可选）</h3>

<p>使用 Nuxt 内置的缓存处理器，减轻服务器压力：</p>

<pre><code class="language-ts">export default defineCachedEventHandler(
  async (event) =&gt; {
    // ... 上面的代码
  },
  {
    maxAge: 60 * 60, // 缓存 1 小时
    name: &quot;feed-cache&quot;,
    getKey: () =&gt; &quot;static&quot;, // 所有用户共享缓存
  },
)
</code></pre>

<h3 id="7-在网页中引入-rss-订阅">7. 在网页中引入 RSS 订阅</h3>

<p>在 <code>app.vue</code> 或布局文件中添加自动发现链接：</p>

<pre><code class="language-vue">// app.vue
&lt;script setup&gt;
const { siteName } = useRuntimeConfig().public

useHead({
  link: [
    {
      rel: &quot;alternate&quot;,
      type: &quot;application/rss+xml&quot;,
      title: siteName,
      href: &quot;/feed.xml&quot;,
    },
    {
      rel: &quot;alternate&quot;,
      type: &quot;application/atom+xml&quot;,
      title: siteName,
      href: &quot;/feed.atom&quot;,
    },
    {
      rel: &quot;alternate&quot;,
      type: &quot;application/json&quot;,
      title: siteName,
      href: &quot;/feed.json&quot;,
    },
  ],
})
&lt;/script&gt;
</code></pre>

<hr>

<h2 id="验证与测试">验证与测试</h2>

<h3 id="本地测试">本地测试</h3>

<pre><code class="language-bash"># 启动服务
pnpm dev

# 测试三种格式
curl http://localhost:3000/feed.xml
curl http://localhost:3000/feed.atom
curl http://localhost:3000/feed.json

# 检查响应头
curl -I http://localhost:3000/feed.xml
</code></pre>

<hr>

<h2 id="常见问题排查">常见问题排查</h2>

<table>
<thead>
<tr>
<th>问题</th>
<th>原因</th>
<th>解决</th>
</tr>
</thead>

<tbody>
<tr>
<td>RSS 显示 <code>[object Object]</code></td>
<td>没有将 <code>doc.body</code> 正确转换为 HTML</td>
<td>使用本文提供的 <code>minimarkToHtml</code> 函数</td>
</tr>

<tr>
<td>链接是相对路径，没有域名</td>
<td>拼接 URL 时遗漏了 <code>siteUrl</code></td>
<td>确保使用 <code>${siteUrl}${doc.path}</code></td>
</tr>

<tr>
<td>日期格式错误</td>
<td>直接使用了 ISO 字符串</td>
<td>RSS 2.0 用 <code>new Date(date).toUTCString()</code>，Atom 和 JSON 用 <code>.toISOString()</code></td>
</tr>

<tr>
<td>生产环境 404</td>
<td><code>server/routes/</code> 下的文件未正确部署</td>
<td>检查构建输出是否包含 <code>.output/server/</code> 目录</td>
</tr>

<tr>
<td>JSON Feed 在浏览器中显示不全</td>
<td>浏览器插件或开发者工具为了性能做了预览截断</td>
<td>直接用 RSS 阅读器测试，或使用 <code>curl</code> 查看完整内容</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="为什么手写比用模块更好">为什么手写比用模块更好？</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>第三方模块</th>
<th>手写方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>代码量</strong></td>
<td>配置复杂，还要写钩子</td>
<td>每个文件约 40 行，简单明了</td>
</tr>

<tr>
<td><strong>依赖</strong></td>
<td>多个间接依赖</td>
<td>零依赖</td>
</tr>

<tr>
<td><strong>学习成本</strong></td>
<td>高（要懂黑盒逻辑）</td>
<td>低（懂 RSS 格式即可）</td>
</tr>

<tr>
<td><strong>调试难度</strong></td>
<td>高（报错看不懂）</td>
<td>极低（哪里错看哪里）</td>
</tr>

<tr>
<td><strong>生产稳定性</strong></td>
<td>容易踩坑</td>
<td>稳定可靠</td>
</tr>

<tr>
<td><strong>维护成本</strong></td>
<td>依赖作者更新</td>
<td>自己掌控</td>
</tr>
</tbody>
</table>

<h3 id="记住">记住</h3>

<p>RSS 的本质就是“查数据 + 拼 XML/JSON”，没有任何复杂逻辑需要模块来封装。</p>

<hr>

<h2 id="进阶优化">进阶优化</h2>

<h3 id="1-分页限制">1. 分页限制</h3>

<pre><code class="language-ts">const docs = await queryCollection(event, &quot;docs&quot;)
  .order(&quot;date&quot;, &quot;DESC&quot;)
  .limit(20) // 只取最近 20 篇
  .all()
</code></pre>

<h3 id="2-自定义命名空间-rss-2-0">2. 自定义命名空间（RSS 2.0）</h3>

<pre><code class="language-ts">xmlns: media = &quot;http://search.yahoo.com/mrss/&quot; // 支持媒体内容
xmlns: dc = &quot;http://purl.org/dc/elements/1.1/&quot; // 支持 Dublin Core
</code></pre>

<h3 id="3-添加更多元数据">3. 添加更多元数据</h3>

<pre><code class="language-ts">// 在 JSON Feed 中添加
&quot;authors&quot;: [{ &quot;name&quot;: &quot;作者名&quot;, &quot;url&quot;: &quot;个人主页&quot; }],
&quot;language&quot;: &quot;zh-CN&quot;,
&quot;tags&quot;: [&quot;技术&quot;, &quot;前端&quot;]
</code></pre>

<hr>

<h2 id="结语">结语</h2>

<p>技术世界总是充满各种“开箱即用”的解决方案，但有时候，<strong>亲手构建一个简单功能所获得的理解和掌控感，远胜于使用复杂的第三方模块</strong>。</p>

<p>RSS 作为一个诞生了 20 多年的简单协议，其魅力就在于透明和可控。通过本文的手写方案，你不仅为博客添加了实用的功能，更深入理解了 Web 的本质。</p>

<p>现在，去享受自己动手的成果吧！🎉</p>
]]></content:encoded>
      <description><![CDATA[手把手教你绕过第三方模块的坑，亲手构建完全可控的 RSS/Atom/JSON Feed 服务。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[SEO]]></category>
      <dc:relation><![CDATA[series:ecosystem]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 评论区完美支持 Markdown：从解析、高亮到安全渲染]]></title>
      <link>https://moongate.top/docs/nuxt-comment-markdown-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-comment-markdown-guide</guid>
      <pubDate>Sat, 21 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="适用版本">📦 适用版本</h2>

<p>本文基于以下版本编写，请确保你的项目版本与之匹配：</p>

<table>
<thead>
<tr>
<th>依赖</th>
<th>版本</th>
<th>备注</th>
</tr>
</thead>

<tbody>
<tr>
<td>Nuxt</td>
<td><strong>v4</strong></td>
<td>核心框架</td>
</tr>

<tr>
<td>Nuxt Content</td>
<td><strong>v3</strong></td>
<td>文档内容管理</td>
</tr>

<tr>
<td>Nuxt UI</td>
<td><strong>v4</strong></td>
<td>提供 <code>useColorMode</code></td>
</tr>

<tr>
<td>marked</td>
<td><strong>v15+</strong></td>
<td>Markdown 解析器</td>
</tr>

<tr>
<td>shiki</td>
<td><strong>v3+</strong></td>
<td>代码高亮引擎</td>
</tr>

<tr>
<td>isomorphic-dompurify</td>
<td><strong>v2+</strong></td>
<td>XSS 防护</td>
</tr>
</tbody>
</table>

<blockquote>
<p>💡 如果你使用其他版本，核心思路仍可参考，但具体 API 可能需要调整。</p>
</blockquote>

<hr>

<details>
<summary>评论区原理</summary>

在开始之前，先聊聊评论区的本质。

很多人（包括我一开始）觉得评论区很复杂——要处理嵌套、要实时更新、要防攻击……但实际上，**一个最小可用的评论区，核心就是最简单的增删改查**：

- **增**：用户提交评论，存到数据库（`INSERT`）
- **删**：用户删除自己的评论（`DELETE`）
- **改**：编辑评论（`UPDATE`，可选）
- **查**：加载文档下的所有评论（`SELECT`）

没有算法、没有实时推送、没有复杂的机制——**就是最基础的后端操作 + 前端展示**。

> 评论区没那么可怕，它只是一个长得像对话框的 CRUD 而已。

本文就是在“增删改查”的基础上，给你的评论加上 **Markdown 渲染** 能力。如果你连基础的评论功能都还没做，可以先花 30 分钟搭一个简单的版本，再回来看本文。

</details>

<hr>

<details>
<summary>前置要求</summary>

本文默认读者已经具备以下能力：

- ✅ **能独立完成评论的基础 CRUD**（数据表设计、API 编写、前端展示）
- ✅ **熟悉 Vue / Nuxt 组件开发**（知道 `props`、`ref`、`watch` 怎么用）
- ✅ **了解 Markdown 基本语法**（知道 `**粗体**`、`` `代码` `` 是什么意思）
- ✅ **能自行查阅文档**（marked、Shiki、DOMPurify 的官网用法）

如果你还不具备这些，建议先补充基础：

- <a href="https://cn.vuejs.org/guide/introduction.html" target="_blank" rel="noopener noreferrer">Vue 3 中文官方文档</a>
- <a href="https://nuxt.zhcndoc.com/docs/4.x/getting-started/installation" target="_blank" rel="noopener noreferrer">Nuxt 4 中文官方文档</a>
- <a href="https://markdown.com.cn/intro.html" target="_blank" rel="noopener noreferrer">Markdown 中文官方文档</a>

**本文不会解释 SQL 怎么写、不会教 Vue 基础、不会重复官网 API——只讲“如何把评论区升级为 Markdown 渲染”。**

</details>

<hr>

<details>
<summary>为什么写这篇文档？</summary>

我在实现评论区 Markdown 功能时，搜遍全网发现：

- 官网文档：只给 API，不给实战
- 个人博客：要么复制粘贴，要么浅尝辄止
- 中文社区：全是其他平台的教程（WordPress、Typecho）

**没有一篇是专门针对 Nuxt 4 的、完整的、经过实战检验的评论区 Markdown 集成教程。**

所以我把自己折腾了七八个小时的过程写下来——包括踩过的坑、填过的土、以及那些“网上搜不到”的解决方案。希望能让后来的人少走些弯路。

</details>

<hr>

<details>
<summary>本文能给你什么</summary>

- ✅ **完整的 Markdown 渲染方案**（marked + Shiki + DOMPurify）
- ✅ **代码块高亮与文档配色统一**（深浅色自动切换）
- ✅ **XSS 防护的正确姿势**（不只是过滤标签）
- ✅ **两种性能方案对比**（预加载 vs 懒加载）
- ✅ **3 个实战踩坑记录**（主题名称不匹配、`$` 替换陷阱、数据流断连）

</details>

<hr>

<h2 id="一-痛点与目标">一、痛点与目标</h2>

<p>许多 Nuxt 博主在搭建评论区时，会遇到以下问题：</p>

<ul>
<li>评论只能输入纯文本，无法贴代码、加粗、列表等。</li>
<li>即使勉强支持 Markdown，代码块样式与文档正文（通常由 Nuxt Content 渲染）不一致，显得格格不入。</li>
<li>担心 XSS 攻击，不敢直接渲染用户输入的 HTML。</li>
</ul>

<h3 id="本文目标">本文目标</h3>

<p>手把手教你为 Nuxt 博客评论区添加<strong>安全、美观、功能完整</strong>的 Markdown 渲染支持，并且代码块配色与文档（Nuxt Content v3）<strong>自动保持统一</strong>，深浅色模式无缝切换。</p>

<hr>

<h2 id="二-技术选型与原理">二、技术选型与原理</h2>

<table>
<thead>
<tr>
<th>需求</th>
<th>选型</th>
<th>理由</th>
</tr>
</thead>

<tbody>
<tr>
<td>Markdown → HTML</td>
<td><code>marked</code></td>
<td>轻量、快速、可扩展，支持 GFM，社区活跃</td>
</tr>

<tr>
<td>代码语法高亮</td>
<td><code>shiki</code></td>
<td>Nuxt Content 同款，主题丰富，输出稳定 HTML</td>
</tr>

<tr>
<td>XSS 防护</td>
<td><code>isomorphic-dompurify</code></td>
<td>SSR 兼容，过滤恶意标签和属性，保障安全</td>
</tr>

<tr>
<td>深浅色模式</td>
<td><code>useColorMode</code> (Nuxt UI)</td>
<td>自动监听系统/用户主题切换，动态更新渲染</td>
</tr>

<tr>
<td>代码块样式统一</td>
<td>CSS 变量 + 覆盖</td>
<td>复用 Nuxt UI 主题变量，使评论块与文档块视觉一致</td>
</tr>
</tbody>
</table>

<h3 id="为什么不用-prism-或-highlight-js">为什么不用 Prism 或 highlight.js？</h3>

<p>Shiki 与 Nuxt Content 内部使用的高亮器一致，可以直接复用其主题和语言包，确保代码块颜色与文档<strong>完全一致</strong>，无需额外维护两套配色。</p>

<blockquote>
<p><strong>主题与语言参考</strong>：Shiki 支持的主题和语言列表请查阅官方文档：<a href="https://shiki.zhcndoc.com/themes" target="_blank">主题列表</a> | <a href="https://shiki.zhcndoc.com/languages" target="_blank">语言列表</a>。你可以根据自己博客的实际配色需求，从中选择与 Nuxt Content 匹配的主题（例如 <code>material-theme-*</code> 系列）。</p>
</blockquote>

<hr>

<h2 id="三-逐步实现-附详细注释">三、逐步实现（附详细注释）</h2>

<h3 id="3-1-安装依赖">3.1 安装依赖</h3>

<pre><code class="language-bash">pnpm add -D shiki marked isomorphic-dompurify
# Nuxt UI v4 已内置 useColorMode，无需额外安装
</code></pre>

<h3 id="3-2-创建-shiki-插件-全局单例">3.2 创建 Shiki 插件（全局单例）</h3>

<p>Shiki 初始化较慢，且应只创建一次。我们通过 Nuxt 插件在客户端创建全局高亮器实例，供所有组件共享。</p>

<pre><code class="language-ts">// plugins/shiki.client.ts
import { createHighlighter } from &quot;shiki&quot;

export default defineNuxtPlugin(async () =&gt; {
  // 预加载文档用到的主题和语言（可根据实际需求调整）
  // 主题列表：https://shiki.zhcndoc.com/themes
  // 语言列表：https://shiki.zhcndoc.com/languages
  const highlighter = await createHighlighter({
    themes: [&quot;material-theme-lighter&quot;, &quot;material-theme-palenight&quot;],
    langs: [
      &quot;javascript&quot;,
      &quot;typescript&quot;,
      &quot;html&quot;,
      &quot;css&quot;,
      &quot;vue&quot;,
      &quot;python&quot;,
      &quot;bash&quot;,
      &quot;json&quot;,
      &quot;markdown&quot;,
      &quot;xml&quot;,
      &quot;yaml&quot;,
      &quot;shell&quot;,
      &quot;diff&quot;,
    ],
  })

  return {
    provide: {
      shiki: highlighter, // 通过 $shiki 注入全局
    },
  }
})
</code></pre>

<h3 id="3-3-封装-markdown-渲染组件">3.3 封装 Markdown 渲染组件</h3>

<p>创建 <code>components/docs/MarkdownRenderer.vue</code>（Nuxt 自动导入名 <code>DocsMarkdownRenderer</code>），核心逻辑如下：</p>

<ul>
<li>使用 <code>marked.Renderer</code> 自定义代码块处理，交给 Shiki 高亮。</li>
<li>监听 <code>colorMode</code> 动态切换主题。</li>
<li>最后通过 DOMPurify 过滤输出。</li>
</ul>

<pre><code class="language-vue">&lt;template&gt;
  &lt;!-- eslint-disable-next-line vue/no-v-html --&gt;
  &lt;div v-html=&quot;renderedContent&quot; /&gt;
&lt;/template&gt;

&lt;script lang=&quot;ts&quot; setup&gt;
import { marked } from &quot;marked&quot;
import DOMPurify from &quot;isomorphic-dompurify&quot;

const props = defineProps({ content: { type: String, required: true } })

// 从 Nuxt 插件中获取全局 Shiki 高亮器实例（已在客户端插件中预加载主题和语言）
const { $shiki } = useNuxtApp()
const colorMode = useColorMode()

const renderedContent = ref(&quot;&quot;)

// 根据当前颜色模式动态选择 Shiki 主题，确保与文档代码块配色一致
const currentTheme = computed(() =&gt; {
  return colorMode.value === &quot;dark&quot;
    ? &quot;material-theme-palenight&quot; // 深色主题
    : &quot;material-theme-lighter&quot; // 浅色主题
})

// 核心渲染函数：将用户输入的 Markdown 内容转换为安全的、高亮的 HTML
const renderContent = async () =&gt; {
  // 如果 Shiki 未就绪或内容为空，则直接显示原始内容（降级处理）
  if (!$shiki || !props.content) {
    renderedContent.value = props.content
    return
  }

  try {
    // ---------- 第一步：手动提取并高亮所有代码块 ----------
    let processed = props.content
    // 正则匹配围栏代码块：```lang\n代码\n```（支持语言可选）
    const codeBlockRegex = /```([a-zA-Z0-9+#-]+)\n([\s\S]*?)```/g
    const matches = [...processed.matchAll(codeBlockRegex)]

    for (const match of matches) {
      const [fullMatch, lang, code] = match
      try {
        // 调用 Shiki 进行语法高亮，返回 HTML 字符串或包含 HTML 的对象
        const highlighted = $shiki.codeToHtml(code.trim(), {
          lang: lang || &quot;text&quot;, // 未指定语言时当作纯文本
          theme: currentTheme.value, // 使用当前主题
        })

        // 兼容 Shiki 不同版本的返回值（可能直接返回字符串，也可能返回 { html } 对象）
        const htmlStr =
          typeof highlighted === &quot;string&quot;
            ? highlighted
            : highlighted.html || highlighted.value || String(highlighted)

        // 用高亮后的 HTML 替换原始代码块（使用函数替换避免 $ 符号被转义）
        processed = processed.replace(fullMatch, () =&gt; htmlStr)
      } catch (e) {
        console.error(&quot;高亮失败:&quot;, e)
        // 高亮失败时保留原始代码块（不做高亮）
      }
    }

    // ---------- 第二步：将处理后的内容（代码块已替换）解析为 Markdown ----------
    const html = await marked.parse(processed, {
      breaks: true, // 将换行符转换为 &lt;br&gt;
      gfm: true, // 启用 GitHub 风格 Markdown（表格、删除线等）
    })

    // ---------- 第三步：使用 DOMPurify 过滤不安全内容，防止 XSS 攻击 ----------
    renderedContent.value = DOMPurify.sanitize(html, {
      // 明确允许的 HTML 标签（涵盖所有 Markdown 可能生成的标签）
      ALLOWED_TAGS: [
        &quot;p&quot;,
        &quot;br&quot;,
        &quot;strong&quot;,
        &quot;em&quot;,
        &quot;u&quot;,
        &quot;s&quot;,
        &quot;del&quot;,
        &quot;ins&quot;,
        &quot;span&quot;,
        &quot;div&quot;,
        &quot;h1&quot;,
        &quot;h2&quot;,
        &quot;h3&quot;,
        &quot;h4&quot;,
        &quot;h5&quot;,
        &quot;h6&quot;,
        &quot;ul&quot;,
        &quot;ol&quot;,
        &quot;li&quot;,
        &quot;a&quot;,
        &quot;blockquote&quot;,
        &quot;code&quot;,
        &quot;pre&quot;,
        &quot;table&quot;,
        &quot;thead&quot;,
        &quot;tbody&quot;,
        &quot;tr&quot;,
        &quot;th&quot;,
        &quot;td&quot;,
        &quot;hr&quot;,
        &quot;img&quot;,
        &quot;sub&quot;,
        &quot;sup&quot;,
      ],
      // 允许的属性（class/style 用于代码高亮样式，其他为链接、图片等常用属性）
      ALLOWED_ATTR: [
        &quot;class&quot;,
        &quot;style&quot;,
        &quot;href&quot;,
        &quot;lang&quot;,
        &quot;src&quot;,
        &quot;alt&quot;,
        &quot;title&quot;,
        &quot;target&quot;,
        &quot;rel&quot;,
      ],
      // 限制 URL 只能使用以下安全协议：
      // - http: / https: → 网页链接、图片链接（评论区核心需求）
      // - ftp: → 文件下载链接（极少出现，但保留无害）
      // - mailto: → 邮箱联系方式（偶尔有人留邮箱）
      // - tel: → 电话联系方式（虽少但保留）
      // - blob: → 临时文件/本地文件（为可能的图片上传预留）
      // - data: → base64 图片（用户直接贴 base64 图片时用）
      // 其他协议（如 javascript:、vbscript:、file: 等）一律拦截，防止 XSS 攻击
      ALLOWED_URI_REGEXP: /^(https?|ftp|mailto|tel|blob|data):/i,

      // 是否允许未在 ALLOWED_URI_REGEXP 中列出的协议：
      // - true  → 正则只作为“推荐列表”，未知协议可能被放行（不安全）
      // - false → 正则作为“强制列表”，只有列出的协议才允许（安全）
      // 评论区场景必须设置为 false，确保所有 URL 都经过协议白名单检查
      ALLOW_UNKNOWN_PROTOCOLS: false,
    })
  } catch (error) {
    console.error(&quot;渲染失败:&quot;, error)
    // 发生任何错误时，回退显示原始内容
    renderedContent.value = props.content
  }
}

// 监听内容或主题变化，立即执行一次渲染，之后每次变化重新渲染
watch([() =&gt; props.content, () =&gt; colorMode.value], renderContent, {
  immediate: true,
})
&lt;/script&gt;

&lt;style scoped&gt;
/* 样式部分见 3.5 节，此处先省略 */
&lt;/style&gt;
</code></pre>

<h3 id="3-4-在评论区中使用">3.4 在评论区中使用</h3>

<p>在你的评论区组件（如 <code>CommentSection.vue</code>）中引入并使用：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div class=&quot;comments&quot;&gt;
    &lt;div v-for=&quot;comment in commentList&quot; :key=&quot;comment.id&quot;&gt;
      &lt;!-- 头像、用户名等 --&gt;
      &lt;DocsMarkdownRenderer :content=&quot;comment.content&quot; /&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<h3 id="3-5-样式统一-让评论代码块与文档融为一体">3.5 样式统一：让评论代码块与文档融为一体</h3>

<p>Nuxt Content 默认代码块样式带有背景、边框和圆角。我们通过 CSS 变量（由 Nuxt UI 提供）覆盖评论区的 <code>&lt;pre&gt;</code> 和 <code>&lt;code&gt;</code> 样式，实现视觉统一。如果你未使用 Nuxt UI，可替换为具体的颜色值。</p>

<p>在 <code>components/docs/MarkdownRenderer.vue</code> 的 <code>&lt;style scoped&gt;</code> 中添加：</p>

<pre><code class="language-css">&lt;style scoped&gt;
/* 代码块整体容器 */
:deep(pre) {
  padding: 1rem;
  background-color: var(--ui-bg-muted);  /* 不使用 !important，通过优先级覆盖 */
  border: 1px solid var(--ui-border);
  border-radius: var(--ui-radius);
  overflow-x: auto;
  margin: 1rem 0;
}

:deep(code) {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 0.9em;
}

/* 行内代码样式 */
:deep(code:not(pre code)) {
  background-color: var(--ui-bg-muted);
  padding: 0.2em 0.4em;
  border-radius: var(--ui-radius-sm);
}

/* 移除 Shiki 可能自带的背景，避免双重背景 */
:deep(.shiki) {
  background-color: transparent !important; /* 这里仍需 !important 覆盖 Shiki 内联样式 */
}

/* 让 .line 元素正常显示，避免布局错乱 */
:deep(pre code .line) {
  display: contents !important;
}

/* 表格样式（与文档保持一致） */
:deep(table) {
  border-collapse: collapse;
  width: 100%;
  margin: 1rem 0;
}
:deep(th), :deep(td) {
  border: 1px solid var(--ui-border);
  padding: 0.5rem;
}
:deep(th) {
  background-color: var(--ui-bg-muted);
  font-weight: 600;
}

/* 引用块 */
:deep(blockquote) {
  border-left: 4px solid var(--ui-border);
  margin: 1rem 0;
  padding: 0.5rem 1rem;
  color: var(--ui-text-muted);
  background-color: var(--ui-bg-muted);
}

/* 列表 */
:deep(ul), :deep(ol) {
  padding-left: 2rem;
}

/* 链接 */
:deep(a) {
  color: var(--ui-primary);
  text-decoration: underline;
}
&lt;/style&gt;
</code></pre>

<blockquote>
<p><strong>提示</strong>：如果你的博客未使用 Nuxt UI，请检查文档代码块的实际样式（背景色、边框色、圆角等），然后将上述 <code>var(--ui-*)</code> 替换为对应的具体颜色值（如 <code>#f6f8fa</code>）。同时可酌情去掉 <code>!important</code>。</p>
</blockquote>

<hr>

<h2 id="四-踩坑与优化记录">四、踩坑与优化记录</h2>

<h3 id="4-1-主题名称不匹配">4.1 主题名称不匹配</h3>

<ul>
<li><strong>问题</strong>：按照 Nuxt Content 文档示例使用 <code>github-light/dark</code>，但实际默认主题可能是 <code>material-theme-lighter/palenight</code>，导致评论区配色与文档不一致。</li>
<li><strong>解决</strong>：打开浏览器检查文档代码块的 <code>&lt;pre&gt;</code> 标签，查看类名（如 <code>material-theme-lighter</code>），或在 <code>nuxt.config.ts</code> 中确认 <code>content.highlight.theme</code> 配置。然后在组件中替换为对应的主题 ID。</li>
</ul>

<h3 id="4-2-string-replace-的-陷阱">4.2 <code>String.replace</code> 的 <code>$</code> 陷阱</h3>

<ul>
<li><strong>问题</strong>：直接用 <code>replace(fullMatch, htmlStr)</code> 时，若 <code>htmlStr</code> 包含 <code>$</code>（如 Shiki 生成的样式），会被解释为特殊替换模式，导致 HTML 损坏。</li>
<li><strong>解决</strong>：使用 <code>replace(fullMatch, () =&gt; htmlStr)</code> 或 <code>replaceAll</code>，避免 <code>$</code> 被转义。</li>
</ul>

<h3 id="4-3-数据流断连">4.3 数据流断连</h3>

<ul>
<li><strong>问题</strong>：提交评论后列表不更新，或修改后的内容没存进数据库。</li>
<li><strong>解决</strong>：检查提交接口是否正确使用了处理后的 <code>contentToSave</code>，并确保评论列表组件监听了数据变化（如用 <code>refresh</code> 重新获取）。</li>
</ul>

<h3 id="4-4-性能优化-预加载-vs-懒加载">4.4 性能优化：预加载 vs 懒加载</h3>

<p>本文提供的方案（插件预加载主题和语言）适合大多数博客，因为评论中常见的语言通常有限。但如果你的博客涉及大量罕见语言，或对首屏加载体积极其敏感，可以考虑<strong>懒加载</strong>方案。</p>

<h4 id="方案一-预加载-当前方案">方案一：预加载（当前方案）</h4>

<ul>
<li>优点：代码块渲染速度最快，无额外异步延迟。</li>
<li>缺点：初始加载时会包含所有预置语言，体积稍大。</li>
</ul>

<h4 id="方案二-懒加载版本-基于-codetohtml">方案二：懒加载版本（基于 <code>codeToHtml</code>）</h4>

<p>方案二同样采用“手动提取代码块 → <code>codeToHtml</code> 高亮 → <code>marked</code> 解析 → DOMPurify 过滤”的完整流程，与方案一的核心区别只有两点：<strong>不依赖全局 Shiki 插件</strong>、<strong>主题与语言按需加载</strong>（首次用到某语言时才加载，之后自动缓存），以优化首屏体积。</p>

<p>由于组件的模板、<code>currentTheme</code> 计算属性、<code>marked.parse</code> 配置、DOMPurify 白名单以及 <code>watch</code> 监听都与 §3.3 的组件<strong>完全一致</strong>，这里不再整段重复，只列出需要修改的三处：</p>

<p><strong>① 导入方式</strong>：不通过插件注入的 <code>$shiki</code>，改为直接使用 <code>shiki</code> 的顶层函数：</p>

<pre><code class="language-ts">import { codeToHtml } from &quot;shiki&quot;
</code></pre>

<p><strong>② 空内容守卫</strong>：去掉对 <code>$shiki</code> 是否就绪的判断：</p>

<pre><code class="language-ts">if (!props.content) {
  renderedContent.value = &quot;&quot;
  return
}
</code></pre>

<p><strong>③ 代码块高亮调用</strong>：把循环里的 <code>$shiki.codeToHtml(...)</code> 换成异步的 <code>await codeToHtml(...)</code>，语言与主题参数不变（保留手动正则提取代码块的逻辑，确保参数类型安全，避免 marked 内部传递不确定对象）：</p>

<pre><code class="language-ts">const highlighted = await codeToHtml(code.trim(), {
  lang: lang || &quot;text&quot;, // 未指定语言时当作纯文本
  theme: currentTheme.value, // 使用当前主题
})
</code></pre>

<p>将以上三处改动套用到 §3.3 的组件上，即可得到完整的懒加载版本。</p>

<h4 id="与方案一-插件预加载-的对比">与方案一（插件预加载）的对比</h4>

<table>
<thead>
<tr>
<th>特性</th>
<th>方案一（预加载插件）</th>
<th>方案二（懒加载 <code>codeToHtml</code>）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Shiki 实例创建</strong></td>
<td>通过插件全局创建一次，预加载所有主题和语言</td>
<td>直接在组件中调用 <code>codeToHtml</code>，按需加载</td>
</tr>

<tr>
<td><strong>初始加载体积</strong></td>
<td>包含所有预置语言，稍大</td>
<td>仅包含核心，语言在用到时才加载</td>
</tr>

<tr>
<td><strong>首次高亮速度</strong></td>
<td>无额外延迟</td>
<td>首次出现某语言时需等待加载（之后缓存）</td>
</tr>

<tr>
<td><strong>代码复杂度</strong></td>
<td>需要维护插件文件</td>
<td>组件内完成，无需额外文件</td>
</tr>

<tr>
<td><strong>适用场景</strong></td>
<td>评论语言种类固定，对渲染速度要求极高</td>
<td>语言种类多，追求首屏性能优化</td>
</tr>
</tbody>
</table>

<h3 id="使用说明">使用说明</h3>

<ol>
<li>删除原有的 <code>plugins/shiki.client.ts</code> 文件（如果存在）。</li>
<li>确保安装了 <code>shiki</code>、<code>marked</code>、<code>isomorphic-dompurify</code>。</li>
<li>根据你的博客实际配色，修改 <code>currentTheme</code> 中的主题 ID（参考 <a href="https://shiki.zhcndoc.com/themes" target="_blank">Shiki 主题列表</a>）。</li>
<li>将 §3.3 的组件套用上述三处改动后，保存为 <code>components/docs/MarkdownRenderer.vue</code>，并在评论区引入使用。</li>
</ol>

<p>该方案已在生产环境中验证，能稳定处理代码块高亮、主题切换、XSS 防护，并实现语言按需加载。</p>

<hr>

<h2 id="五-总结与扩展">五、总结与扩展</h2>

<p>至此，你拥有了一个功能完备、安全可靠的评论区，支持：</p>

<ul>
<li>完整的 Markdown 语法（标题、列表、表格、引用、图片等）</li>
<li>代码块语法高亮（与文档配色一致）</li>
<li>深浅色模式自动切换</li>
<li>XSS 防护</li>
</ul>

<p>在此基础上，你还可以继续扩展：</p>

<ul>
<li>添加评论回复功能</li>
<li>支持表情符号（如 <code>marked-emoji</code> 插件）</li>
<li>实时预览（Markdown 编辑器）</li>
</ul>

<hr>

<h2 id="附录-常见问题">附录：常见问题</h2>

<h3 id="q-我用的不是-nuxt-ui-如何实现主题切换">Q：我用的不是 Nuxt UI，如何实现主题切换？</h3>

<p>A：可以使用 <code>@vueuse/core</code> 的 <code>usePreferredDark</code> 手动监听系统主题，动态改变 Shiki 的 <code>theme</code> 参数。</p>

<h3 id="q-如何支持更多编程语言">Q：如何支持更多编程语言？</h3>

<p>A：语言标识符请参考 <a href="https://shiki.zhcndoc.com/languages" target="_blank">Shiki 官方语言列表</a>。Shiki 会自动加载所需语言，无需额外配置。若使用插件预加载，只需在 <code>langs</code> 数组中添加对应 ID。</p>

<h3 id="q-渲染速度慢怎么办">Q：渲染速度慢怎么办？</h3>

<p>A：如果选择预加载方案，确保 Shiki 实例全局单例（插件方式已满足）。如果选择懒加载方案，首次加载某种语言时会有短暂延迟，但之后会缓存。若评论数量极大，可考虑对代码块渲染做虚拟滚动。</p>

<h3 id="q-如何确认文档实际使用的主题">Q：如何确认文档实际使用的主题？</h3>

<p>A：打开浏览器开发者工具，选中文档中的一个代码块，查看 <code>&lt;pre&gt;</code> 或 <code>&lt;code&gt;</code> 标签的类名，通常包含主题名称（如 <code>material-theme-palenight</code>）。也可在 <code>nuxt.config.ts</code> 中查看 <code>content.highlight.theme</code> 配置。</p>
]]></content:encoded>
      <description><![CDATA[手把手教你为 Nuxt 博客评论区添加安全、美观、功能完整的 Markdown 渲染支持，代码块配色与文档（Nuxt Content）自动统一，深浅色模式无缝切换。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Security]]></category>
      <dc:relation><![CDATA[series:comment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 中 URL 与状态双向绑定指南：从原理到实践]]></title>
      <link>https://moongate.top/docs/nuxt-url-state-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-url-state-guide</guid>
      <pubDate>Thu, 19 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p>本文完整记录了在 Nuxt 4 中实现 URL 与页面状态双向同步的全过程，涵盖分页、搜索、多选标签、等级筛选等复杂场景，并深入探讨 SSR 安全、组件拆分陷阱、键盘事件与移动端手势的协同。文末提供两种可直接复用的实现方案（手写 watch 与 Pinia 封装），并附上生产环境验证过的踩坑总结。</p>
</blockquote>

<hr>

<h2 id="引言-一个看似简单的需求">引言：一个看似简单的需求</h2>

<p>在开发文档列表页时，我们通常需要支持<strong>分页</strong>和<strong>搜索</strong>。为了让用户能够通过链接分享当前页面状态，我们很自然地把页码、搜索词放到 URL query 里，例如 <code>/docs?page=2&amp;search=nuxt</code>。</p>

<p>这个需求看似简单，但实现后常遇到两个头疼的问题：</p>

<ol>
<li><strong>点击浏览器后退按钮，URL 变了，页面数据却没变。</strong></li>
<li><strong>直接修改 URL 参数回车，数据更新了，但输入框显示的还是旧值。</strong></li>
</ol>

<p>这些问题根源在于 <strong>内部状态与 URL 不同步</strong>。本文将带你从原理到实战，完整解决这个问题，并分享一次因盲目相信官方模块而踩坑的真实经历。</p>

<hr>

<h2 id="一-常见错误尝试-引以为戒">一、常见错误尝试（引以为戒）</h2>

<p>在进入正解之前，我们先看看一些常见的错误写法，以及它们为什么不行。</p>

<h3 id="错误-1-只监听分页推路由-不同步搜索词">❌ 错误 1：只监听分页推路由，不同步搜索词</h3>

<pre><code class="language-ts">watch([() =&gt; pagination.page, () =&gt; pagination.size], () =&gt; {
  router.push({ query: { page: pagination.page, size: pagination.size } })
})
</code></pre>

<p><strong>问题</strong>：如果 URL 中还有 <code>search</code> 参数，当用户点击返回按钮时，<code>route.query</code> 的 <code>search</code> 变了，但内部的 <code>searchValue</code> 没有更新，导致数据获取时使用的是旧搜索词。</p>

<h3 id="错误-2-在-useasyncdata-中手动调用-refresh">❌ 错误 2：在 <code>useAsyncData</code> 中手动调用 <code>refresh</code></h3>

<pre><code class="language-ts">const { refresh } = useAsyncData(...)
watch(() =&gt; route.query, () =&gt; {
  refresh()  // 手动刷新
})
</code></pre>

<p><strong>问题</strong>：</p>

<p><code>refresh</code> 会强制重新执行 fetcher，但如果你的 fetcher 内部依赖的响应式变量没有更新，可能还是旧数据。而且手动调用容易产生重复请求，破坏数据流的单向性。</p>

<h3 id="错误-3-useasyncdata-的-watch-依赖不全">❌ 错误 3：<code>useAsyncData</code> 的 <code>watch</code> 依赖不全</h3>

<pre><code class="language-ts">watch: [() =&gt; pagination.page, () =&gt; pagination.size] // 漏了 searchValue
</code></pre>

<p><strong>问题</strong>：</p>

<p><code>searchValue</code> 变化时，<code>useAsyncData</code> 不会自动重新获取，数据与 URL 不匹配。</p>

<h3 id="错误-4-忽略数组参数的处理">❌ 错误 4：忽略数组参数的处理</h3>

<pre><code class="language-ts">// 假设 URL 中有 ?tag=Nuxt,Vue
const tags = ref(route.query.tag?.split(&quot;,&quot;)) // 如果 tag 不存在，会报错
</code></pre>

<p><strong>问题</strong>：没有处理 <code>undefined</code> 或数组格式（如 <code>?tag=Nuxt&amp;tag=Vue</code>），且序列化时未考虑数组。</p>

<h3 id="错误-5-在子组件中重复调用-useresponsive">❌ 错误 5：在子组件中重复调用 <code>useResponsive</code></h3>

<p><strong>问题</strong>：在 SSR 环境下，<code>isMobile</code> 等环境敏感值如果在子组件内重复调用，可能导致服务端和客户端判断不一致，引发水合失败。</p>

<hr>

<h2 id="二-理想方案-双向同步闭环">二、理想方案：双向同步闭环</h2>

<h3 id="核心思想">核心思想</h3>

<ul>
<li><strong>URL 是唯一真实源</strong>：所有影响数据的状态（page, size, search, level, tags 等）都应与 URL 同步。</li>
<li><strong><code>useAsyncData</code> 的 <code>watch</code> 自动刷新</strong>：列全依赖，无需手动调用 <code>refresh</code>。</li>
</ul>

<h3 id="2-1-双向同步的流程图">2.1 双向同步的流程图</h3>

<pre><code class="language-text">┌─────────────────────────────────────────────────────────────────┐
│                        双向同步闭环                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   用户操作                        浏览器后退/前进/直接修改URL    │
│      │                                   │                      │
│      ▼                                   ▼                      │
│  修改内部状态                    ┌─────────────────┐            │
│  (ref)                          │ route.query 变化 │            │
│      │                          └────────┬────────┘            │
│      ▼                                   │                      │
│  watch(状态) 触发                 watch(route.query) 触发        │
│      │                                   │                      │
│      └─────────────┬─────────────────────┘                      │
│                    ▼                                            │
│            router.push 更新 URL                                 │
│                    │                                            │
│                    ▼                                            │
│         useAsyncData 自动重新获取数据                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
</code></pre>

<h3 id="2-2-手写-watch-的核心原理">2.2 手写 watch 的核心原理</h3>

<pre><code class="language-ts">// 1. 从 URL 初始化内部状态
const route = useRoute()
const router = useRouter()

const searchInput = ref(route.query.search?.toString() || &quot;&quot;)
const searchOption = ref(Number(route.query.option) || 1)
const page = ref(Number(route.query.page) || 1)
const size = ref(Number(route.query.size) || 10)
const level = ref(route.query.level?.toString() || &quot;&quot;)
const viewMode = ref(Number(route.query.viewMode) || 1)
const tags = ref&lt;string[]&gt;([])

// 解析 URL 中的数组参数（支持逗号分隔或重复键名）
const parseTagsFromQuery = () =&gt; {
  const tagParam = route.query.tag
  tags.value = tagParam
    ? Array.isArray(tagParam)
      ? tagParam
      : tagParam.split(&quot;,&quot;)
    : []
}
parseTagsFromQuery()

// 2. Watch 1：URL → 内部状态（处理后退/直接访问）
watch(
  () =&gt; route.query,
  (q) =&gt; {
    searchInput.value = q.search?.toString() || &quot;&quot;
    searchOption.value = Number(q.option) || 1
    page.value = Number(q.page) || 1
    size.value = Number(q.size) || 10
    level.value = q.level?.toString() || &quot;&quot;
    viewMode.value = Number(q.viewMode) || 1
    parseTagsFromQuery()
  },
  { immediate: true },
)

// 3. Watch 2：内部状态 → URL（用户操作时同步）
watch([searchInput, searchOption, page, size, level, viewMode, tags], () =&gt; {
  const query: Record&lt;string, string&gt; = {}
  if (searchInput.value) query.search = searchInput.value
  if (searchOption.value !== 1) query.option = String(searchOption.value)
  if (page.value !== 1) query.page = String(page.value)
  if (size.value !== 10) query.size = String(size.value)
  if (level.value) query.level = level.value
  if (viewMode.value !== 1) query.viewMode = String(viewMode.value)
  if (tags.value.length) query.tag = tags.value.join(&quot;,&quot;)

  // 避免无意义的重复跳转
  if (JSON.stringify(route.query) !== JSON.stringify(query)) {
    router.push({ query })
  }
})

// 4. 数据获取：useAsyncData 自动刷新
const { data } = useAsyncData(
  &quot;docs&quot;,
  async () =&gt; {
    // 使用当前状态构建查询
    let query = queryCollection(&quot;docs&quot;).order(&quot;date&quot;, &quot;DESC&quot;)
    if (searchInput.value) {
      /* ... */
    }
    if (level.value) query = query.where(&quot;level&quot;, &quot;=&quot;, level.value)
    if (tags.value.length) {
      tags.value.forEach((tag) =&gt; {
        query = query.where(&quot;tags&quot;, &quot;LIKE&quot;, `%${tag}%`)
      })
    }
    return query
      .skip((page.value - 1) * size.value)
      .limit(size.value)
      .all()
  },
  {
    watch: [searchInput, searchOption, page, size, level, viewMode, tags], // 直接监听 ref
  },
)
</code></pre>

<h4 id="关键点">关键点</h4>

<ul>
<li>数组参数（<code>tags</code>）在解析时兼容逗号分隔和重复键名，序列化时统一用逗号分隔。</li>
<li><code>watch</code> 中直接使用 ref 本身，确保数组内部变化（如 <code>push</code>/<code>pop</code>）能被正确捕获。</li>
<li>只将非默认值的参数写入 URL，保持 URL 简洁。</li>
</ul>

<blockquote>
<p>💡 <strong>系列定位</strong>：本文的手写方案是整个系列的基线实现。第 2 篇<a href="./nuxt-docs-list-page-complete-guide">《从零到一：构建一个功能完备的文档列表页》</a>将其应用到真实页面；第 3 篇<a href="./nuxt-use-route-query-composables">《手写一个更适合 Nuxt 的 useRouteQuery》</a>将其封装为可复用函数；第 4 篇延伸至 Go 后端。后文遇到&rdquo;与第 1 篇相同&rdquo;的代码时，均以本篇为权威源，不再重复展开。</p>
</blockquote>

<hr>

<h2 id="三-官方捷径-一次尝试与回归">三、官方捷径？—— 一次尝试与回归</h2>

<p>在完成手写版本后，我了解到 <code>@vueuse/router</code> 提供了 <code>useRouteQuery</code> 这个工具，它可以用更少的代码实现类似功能：</p>

<pre><code class="language-ts">import { useRouteQuery } from &quot;@vueuse/router&quot;
const search = useRouteQuery(&quot;search&quot;, &quot;&quot;)
const searchOption = useRouteQuery(&quot;option&quot;, 1, { transform: Number })
const page = useRouteQuery(&quot;page&quot;, 1, { transform: Number })
const size = useRouteQuery(&quot;size&quot;, 10, { transform: Number })
</code></pre>

<p>于是我用它重构了代码，开发环境一切正常。然而在部署到生产环境后，页面却返回了 500 错误：</p>

<pre><code class="language-text">Server Error
Invalid value used as weak map key
</code></pre>

<p>经过数小时的排查，我发现只要移除 <code>useRouteQuery</code> 相关代码，问题就消失。虽然我尝试了各种方法（升级版本、清理缓存、简化项目），但最终未能彻底查明原因。考虑到项目上线的时间压力，我决定放弃 <code>useRouteQuery</code>，回归到手写方案。</p>

<p>这个经历让我意识到：<strong>在生产环境中，稳定可控的方案往往比“看起来简洁”的方案更重要</strong>。手写方案虽然代码稍多，但每一行都在自己的掌控之中，排查问题也更容易。</p>

<blockquote>
<p><strong>后续思考</strong>：官方 <code>useRouteQuery</code> 的 <code>WeakMap</code> 批量更新机制在 SSR 中可能引发跨请求污染，而手写方案完全避免了全局状态。后来我封装了一套更适合自己项目的 <code>useRouteQueryString</code>、<code>useRouteQueryNumber</code> 等函数，具体见 <a href="./nuxt-use-route-query-composables.md">手写一个更适合 Nuxt 的 useRouteQuery：简化 URL 状态同步</a>。</p>
</blockquote>

<hr>

<h2 id="四-ssr-安全深度剖析">四、SSR 安全深度剖析</h2>

<p>在 Nuxt 中，水合失败（Hydration Mismatch）是常见问题。为了保证 SSR 安全，必须遵循以下原则：</p>

<ol>
<li><p><strong>初始状态必须从 URL 同步读取</strong><br>
所有影响 DOM 的状态（如 <code>page</code>、<code>tags</code>）应在组件顶层从 <code>route.query</code> 初始化，而不是在 <code>onMounted</code> 中从 <code>localStorage</code> 或客户端 API 获取。这样服务端和客户端第一次渲染时看到的初始值完全一致。</p></li>

<li><p><strong>环境敏感值（如 <code>isMobile</code>）只在根组件计算，通过 props 传递</strong><br>
如果在子组件中直接调用 <code>useResponsive</code>，由于服务端无法获取真实的屏幕宽度（<code>ssrWidth</code> 只是一个近似值），可能导致 <code>isMobile</code> 在服务端为 <code>false</code>，客户端为 <code>true</code>，从而引发 <code>v-if</code> 分支差异。正确做法是在根组件中计算一次，然后通过 props 向下传递。</p></li>

<li><p><strong>累积列表逻辑仅在客户端且 <code>page &gt; 1</code> 时执行</strong><br>
移动端无限滚动时，如果在水合阶段就合并数据，会导致服务端和客户端列表长度不一致。通过条件 <code>if (isMobile.value &amp;&amp; page.value &gt; 1)</code> 可以保证水合时不会执行合并，数据长度与服务端一致。</p></li>

<li><p><strong><code>useAsyncData</code> 的 <code>watch</code> 直接使用 ref</strong><br>
使用 <code>() =&gt; tags.value</code> 这种 getter 形式可能无法正确追踪数组内部变化，直接传入 <code>tags</code> 可确保 <code>tags</code> 数组的任何变化都能触发数据重新获取。</p></li>
</ol>

<hr>

<h2 id="五-与组件拆分时的陷阱">五、与组件拆分时的陷阱</h2>

<p>当你将页面拆分为多个子组件时，如果某个子组件需要 <code>isDesktop</code> 值，请务必从父组件传入，而不是在子组件内部再次调用 <code>useResponsive</code>。例如：</p>

<h3 id="父组件-index-vue">父组件（index.vue）</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;TagFilter :is-desktop=&quot;isDesktop&quot; ... /&gt;
&lt;/template&gt;
&lt;script setup&gt;
const { isDesktop } = useResponsive()
&lt;/script&gt;
</code></pre>

<h3 id="子组件-tagfilter-vue">子组件（TagFilter.vue）</h3>

<pre><code class="language-vue">&lt;script setup&gt;
const props = defineProps([&quot;isDesktop&quot;])
// 内部使用 props.isDesktop，不再调用 useResponsive
&lt;/script&gt;
</code></pre>

<p>这样可以保证服务端和客户端对 <code>isDesktop</code> 的判断一致，避免水合失败。</p>

<hr>

<h2 id="六-扩展场景-与键盘事件-移动端手势协同">六、扩展场景：与键盘事件、移动端手势协同</h2>

<p>URL 状态同步不仅适用于分页、搜索等基础操作，还能与用户交互无缝结合。</p>

<h3 id="6-1-键盘左右键翻页">6.1 键盘左右键翻页</h3>

<pre><code class="language-ts">useEventListener(&quot;keydown&quot;, (e) =&gt; {
  if (e.key === &quot;ArrowLeft&quot; &amp;&amp; hasPrevPage.value) {
    e.preventDefault()
    page.value -= 1 // page 变化会自动触发 URL 更新和数据刷新
  }
  // ...
})
</code></pre>

<h3 id="6-2-移动端无限滚动">6.2 移动端无限滚动</h3>

<pre><code class="language-ts">useSwipe({
  onUp: () =&gt; {
    if (hasNextPage.value) page.value += 1
  },
})
</code></pre>

<p>这些交互只需修改 <code>page</code>、<code>tags</code> 等 ref，URL 和数据会自动同步，无需额外代码。</p>

<hr>

<h2 id="七-方案对比与选型建议">七、方案对比与选型建议</h2>

<table>
<thead>
<tr>
<th>方案</th>
<th>代码量</th>
<th>可维护性</th>
<th>SSR 安全</th>
<th>适用场景</th>
</tr>
</thead>

<tbody>
<tr>
<td>手写 watch</td>
<td>中等</td>
<td>高</td>
<td>✅ 安全</td>
<td>单页面，追求绝对控制</td>
</tr>

<tr>
<td>Pinia 封装</td>
<td>较多</td>
<td>最高</td>
<td>✅ 安全</td>
<td>多页面复用，工程化项目</td>
</tr>

<tr>
<td><code>useRouteQuery</code></td>
<td>最少</td>
<td>一般</td>
<td>⚠️需验证</td>
<td><strong>简单场景，但建议先测试</strong></td>
</tr>
</tbody>
</table>

<h3 id="pinia-封装示例-可折叠">Pinia 封装示例（可折叠）</h3>

<details>
<summary>点击展开 Pinia 方案代码</summary>

<pre><code class="language-ts">// stores/urlQuery.ts
import { defineStore } from &quot;pinia&quot;

export const useUrlQueryStore = defineStore(&quot;urlQuery&quot;, () =&gt; {
  const route = useRoute()
  const router = useRouter()

  const search = ref(route.query.search?.toString() || &quot;&quot;)
  const option = ref(Number(route.query.option) || 1)
  const page = ref(Number(route.query.page) || 1)
  const size = ref(Number(route.query.size) || 10)
  const level = ref(route.query.level?.toString() || &quot;&quot;)
  const viewMode = ref(Number(route.query.viewMode) || 1)
  const tags = ref&lt;string[]&gt;([])

  const parseTags = () =&gt; {
    const tagParam = route.query.tag
    tags.value = tagParam
      ? Array.isArray(tagParam)
        ? tagParam
        : tagParam.split(&quot;,&quot;)
      : []
  }
  parseTags()

  watch(
    () =&gt; route.query,
    (q) =&gt; {
      search.value = q.search?.toString() || &quot;&quot;
      option.value = Number(q.option) || 1
      page.value = Number(q.page) || 1
      size.value = Number(q.size) || 10
      level.value = q.level?.toString() || &quot;&quot;
      viewMode.value = Number(q.viewMode) || 1
      parseTags()
    },
  )

  const pushQuery = () =&gt; {
    const query: Record&lt;string, string&gt; = {}
    if (search.value) query.search = search.value
    if (option.value !== 1) query.option = String(option.value)
    if (page.value !== 1) query.page = String(page.value)
    if (size.value !== 10) query.size = String(size.value)
    if (level.value) query.level = level.value
    if (viewMode.value !== 1) query.viewMode = String(viewMode.value)
    if (tags.value.length) query.tag = tags.value.join(&quot;,&quot;)

    if (JSON.stringify(route.query) !== JSON.stringify(query)) {
      router.push({ query })
    }
  }

  watch([search, option, page, size, level, viewMode, tags], () =&gt; pushQuery())

  return { search, option, page, size, level, viewMode, tags }
})</code></pre>

</details>

<hr>

<h2 id="八-与-localstorage-的对比">八、与 localStorage 的对比</h2>

<table>
<thead>
<tr>
<th>特性</th>
<th>URL Query</th>
<th>localStorage</th>
</tr>
</thead>

<tbody>
<tr>
<td>可分享</td>
<td>✅ 直接复制链接</td>
<td>❌ 无法分享</td>
</tr>

<tr>
<td>后退/前进</td>
<td>✅ 天然支持</td>
<td>❌ 需手动监听</td>
</tr>

<tr>
<td>SSR 可用</td>
<td>✅ 是</td>
<td>❌ 否</td>
</tr>

<tr>
<td>适用场景</td>
<td>分页、搜索、筛选</td>
<td>用户偏好（如主题）</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="九-一点感悟">九、一点感悟</h2>

<p>这次经历让我更深刻地体会到：<strong>在生产环境中，稳定性和可维护性往往比代码的简洁性更重要</strong>。手写方案虽然需要多写几行代码，但每一行都在自己的掌控之中，排查问题也更直接。同时，我也学会了在遇到诡异问题时，要有耐心逐步缩小范围，最终找到适合自己的解决方案。</p>

<blockquote>
<p>💡 <strong>如果你希望进一步简化代码</strong>，可以参考我封装的开箱即用版 <code>useRouteQueryString</code>、<code>useRouteQueryNumber</code>、<code>useRouteQueryArray</code>，详见 <a href="./nuxt-use-route-query-composables">《手写一个更适合 Nuxt 的 useRouteQuery》</a>。</p>
</blockquote>

<hr>

<h2 id="十-结语">十、结语</h2>

<p>本文从 URL 状态同步的常见问题出发，介绍了错误做法，给出了手写 watch 和 Pinia 两种稳定可靠的方案，并深入探讨了 SSR 安全、数组参数处理、组件拆分陷阱等实际工程中的难点。希望这些内容能帮助你在自己的项目中少走弯路。</p>

<blockquote>
<p>如果你需要持久化用户偏好（如主题、语言），可以参考我的文档<a href="./nuxt-state-persistence-guide">《Nuxt 4 中安全实现状态持久化》</a>。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[深入探讨 Nuxt 中 URL 与状态双向绑定的原理，解决后退按钮数据不刷新、输入框与 URL 不一致等常见问题。从错误尝试到正确实践，提供手写 watch 和 Pinia 两种稳定可靠的 SSR 安全方案，并对比与 localStorage 的适用场景。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Vue]]></category>
      <category><![CDATA[State Management]]></category>
      <dc:relation><![CDATA[series:url-state]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 图片引用：<NuxtImg> 替代原生 <img> 的一次实践]]></title>
      <link>https://moongate.top/docs/nuxt-image-best-practice</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-image-best-practice</guid>
      <pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="从一次诡异的图片-404-错误说起">从一次诡异的图片 404 错误说起</h2>

<p>如果你在 Nuxt 项目中使用原生 <code>&lt;img&gt;</code> 标签引用图片，比如：</p>

<pre><code class="language-vue">&lt;img src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝赞赏码&quot; /&gt;
</code></pre>

<p>有没有遇到过这样的情况：</p>

<ul>
<li>在<strong>页面里</strong>直接写，图片正常显示 ✅</li>
<li>把同样的代码<strong>封装到组件里</strong>，图片突然不显示了 ❌</li>
<li>打开开发者工具，发现请求的 URL 变成了 <code>http://localhost:3000/&amp;/images/ali-pay.jpg</code></li>
<li>控制台报错：<code>No match found for location with path &quot;/&amp;/images/ali-pay.jpg&quot;</code></li>
</ul>

<p>我就在最近踩了这个坑。更诡异的是：</p>

<ul>
<li>强制 <code>&lt;details&gt;</code> 默认展开也报错 ❌</li>
<li>整个页面刷新后才正常显示 ✅</li>
<li>换成 <code>&lt;NuxtImg&gt;</code> 组件后，问题消失 ✅</li>
</ul>

<p>这篇文档不是要深挖这个错误的技术根源（因为可能涉及你的具体配置），而是要告诉你一个更重要的结论：</p>

<p><strong>在 Nuxt 项目中，永远优先使用 <code>&lt;NuxtImg&gt;</code> 而不是原生 <code>&lt;img&gt;</code>。</strong></p>

<hr>

<h2 id="原生-img-的隐患">📊 原生 <code>&lt;img&gt;</code> 的隐患</h2>

<table>
<thead>
<tr>
<th>场景</th>
<th>原生 <code>&lt;img&gt;</code></th>
<th><code>&lt;NuxtImg&gt;</code></th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>项目部署在子目录</strong>（如 <code>https://a.com/blog/</code>）</td>
<td>需要手动拼接 <code>baseURL</code></td>
<td>✅ 自动处理</td>
</tr>

<tr>
<td><strong>动态路由页面中使用</strong>（如 <code>[...slug].vue</code>）</td>
<td>可能路径解析错误</td>
<td>✅ 稳定可靠</td>
</tr>

<tr>
<td><strong>组件内使用</strong></td>
<td>可能受组件上下文影响</td>
<td>✅ 始终如一</td>
</tr>

<tr>
<td><strong>图片优化</strong></td>
<td>无</td>
<td>✅ 支持格式转换、尺寸调整、懒加载</td>
</tr>

<tr>
<td><strong>开发服务器热重载</strong></td>
<td>可能缓存问题</td>
<td>✅ 优化良好</td>
</tr>
</tbody>
</table>
<p>换句话说，原生 <code>&lt;img&gt;</code> 不是“不能用”，而是有太多“可能出问题”的场景，尤其是当你的项目稍微复杂一点时。</p>

<hr>

<h2 id="对比实验">🧪 对比实验</h2>

<h3 id="原生-img-组件版">❌ 原生 <code>&lt;img&gt;</code> 组件版</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;details&gt;
    &lt;summary&gt;请我喝杯咖啡 ☕️&lt;/summary&gt;
    &lt;img src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝&quot; class=&quot;w-32&quot; /&gt;
  &lt;/details&gt;
&lt;/template&gt;
</code></pre>

<p><strong>结果</strong>：请求 <code>/&amp;/images/ali-pay.jpg</code> → 404</p>

<h3 id="nuxtimg-组件版">✅ <code>&lt;NuxtImg&gt;</code> 组件版</h3>

<pre><code class="language-vue">&lt;template&gt;
  &lt;details&gt;
    &lt;summary&gt;请我喝杯咖啡 ☕️&lt;/summary&gt;
    &lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝&quot; class=&quot;w-32&quot; /&gt;
  &lt;/details&gt;
&lt;/template&gt;
</code></pre>

<p><strong>结果</strong>：请求 <code>/images/ali-pay.jpg</code> → 200 ✅</p>

<p>唯一的区别就是用了 <code>&lt;NuxtImg&gt;</code> 替换 <code>&lt;img&gt;</code>。</p>

<hr>

<h2 id="安装和使用">🔧 安装和使用</h2>

<h3 id="1-安装模块">1. 安装模块</h3>

<pre><code class="language-bash"># 使用 npm
npm install @nuxt/image
# 使用 pnpm
pnpm add @nuxt/image
</code></pre>

<h3 id="2-添加到-nuxt-config-ts">2. 添加到 <code>nuxt.config.ts</code></h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;@nuxt/image&quot;],
})
</code></pre>

<h3 id="3-在组件中使用">3. 在组件中使用</h3>

<pre><code class="language-vue">&lt;!-- 基础用法 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; alt=&quot;支付宝&quot; /&gt;
&lt;!-- 指定宽度，自动优化 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; width=&quot;200&quot; height=&quot;200&quot; /&gt;
&lt;!-- 响应式图片 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; sizes=&quot;sm:100vw md:50vw lg:400px&quot; /&gt;
&lt;!-- 懒加载 --&gt;
&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot; loading=&quot;lazy&quot; /&gt;
</code></pre>

<hr>

<h2 id="为什么-nuxtimg-更可靠">🎯 为什么 <code>&lt;NuxtImg&gt;</code> 更可靠</h2>

<h3 id="1-自动处理-baseurl">1. 自动处理 <code>baseURL</code></h3>

<p>如果你在 <code>nuxt.config.ts</code> 中配置了：</p>

<pre><code class="language-typescript">export default defineNuxtConfig({
  app: {
    baseURL: &quot;/blog/&quot;,
  },
})
</code></pre>

<ul>
<li>原生 <code>&lt;img src=&quot;/images/ali-pay.jpg&quot;&gt;</code> 会请求 <code>/blog/images/ali-pay.jpg</code>？<strong>不会</strong>，它还是请求 <code>/images/ali-pay.jpg</code>，404。</li>
<li><code>&lt;NuxtImg src=&quot;/images/ali-pay.jpg&quot;&gt;</code> 会自动加上 <code>/blog/</code> 前缀，请求正确地址。</li>
</ul>

<h3 id="2-内置图片优化服务">2. 内置图片优化服务</h3>

<p><code>@nuxt/image</code> 会在开发环境启动一个图片优化中间件，在生产环境生成优化后的图片：</p>

<ul>
<li>支持 WebP 等现代格式（自动根据浏览器选择）</li>
<li>支持图片裁剪、缩放</li>
<li>支持响应式图片（根据屏幕尺寸加载不同大小）</li>
<li>支持懒加载</li>
</ul>

<h3 id="3-构建时处理">3. 构建时处理</h3>

<p>生产构建时，<code>@nuxt/image</code> 会：</p>

<ul>
<li>将图片复制到输出目录</li>
<li>生成哈希化的文件名（<code>ali-pay.abc123.jpg</code>）</li>
<li>避免路径冲突和缓存问题</li>
</ul>

<h3 id="4-官方维护-社区验证">4. 官方维护，社区验证</h3>

<p>作为 Nuxt 官方模块，它经过了大量项目的测试，适配各种部署场景（Vercel、Netlify、自托管等）。</p>

<hr>

<h2 id="最佳实践总结">💡 最佳实践总结</h2>

<h3 id="推荐做法">✅ 推荐做法</h3>

<ol>
<li><strong>所有内部图片</strong>：使用 <code>&lt;NuxtImg&gt;</code> 或 <code>&lt;NuxtPicture&gt;</code></li>
<li><strong>外部图片</strong>（如 CDN）：配置 <code>provider</code> 后同样使用 <code>&lt;NuxtImg&gt;</code></li>
<li><strong>结合 <code>sizes</code> 属性</strong>：实现响应式图片，提升性能</li>
<li><strong>开启懒加载</strong>：对长页面的图片设置 <code>loading=&quot;lazy&quot;</code></li>
</ol>

<h3 id="避免的做法">❌ 避免的做法</h3>

<ol>
<li><strong>直接使用原生 <code>&lt;img&gt;</code></strong>，除非你有特殊理由</li>
<li><strong>手动拼接 <code>baseURL</code></strong>，交给 <code>&lt;NuxtImg&gt;</code> 处理</li>
<li><strong>引用 <code>public</code> 目录外的图片</strong>，保持项目结构清晰</li>
</ol>

<hr>

<h2 id="额外收获-图片优化带来的性能提升">🎁 额外收获：图片优化带来的性能提升</h2>

<p>除了解决路径问题，<code>@nuxt/image</code> 还能显著提升网站性能：</p>

<table>
<thead>
<tr>
<th>优化项</th>
<th>效果</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>格式转换</strong></td>
<td>自动将 JPEG/PNG 转为 WebP（节省 30-50% 体积）</td>
</tr>

<tr>
<td><strong>尺寸调整</strong></td>
<td>只加载当前视口需要的图片大小</td>
</tr>

<tr>
<td><strong>懒加载</strong></td>
<td>减少初始加载时间</td>
</tr>

<tr>
<td><strong>预连接</strong></td>
<td>可配置 CDN 预连接，加速加载</td>
</tr>
</tbody>
</table>
<p>用 Lighthouse 测试一下，你会发现图片相关分数明显提高。</p>

<hr>

<h2 id="结语">📝 结语</h2>

<p>那个让我研究了半小时的 <code>/&amp;/</code> 报错，最后被一个官方模块轻松解决。这让我意识到：</p>

<p><strong>在 Nuxt 项目里，能用官方模块解决的问题，就尽量不要自己手写。</strong></p>

<p><code>@nuxt/image</code> 不只是“图片组件”，它是 Nuxt 官方提供的一整套图片处理方案。安装它，不仅能避免各种诡异的路径问题，还能免费获得图片优化、响应式支持等高级功能。</p>

<p>如果你还没用上，现在就去装一个：</p>

<pre><code class="language-bash">npx nuxi@latest module add image
</code></pre>

<p>然后：</p>

<pre><code class="language-vue">&lt;NuxtImg src=&quot;/images/your-image.jpg&quot; /&gt;
</code></pre>

<p>世界清净了。</p>

<hr>

<h3 id="遇到过的坑">遇到过的坑</h3>

<p>图片路径 <code>/&amp;/</code> 错误、组件内图片不显示、部署子目录后图片 404</p>

<h3 id="用过的方案">用过的方案</h3>

<p>原生 <code>&lt;img&gt;</code>、显式 import、<code>&lt;NuxtImg&gt;</code></p>

<h3 id="最终的答案">最终的答案</h3>

<p>永远优先用 <code>&lt;NuxtImg&gt;</code></p>
]]></content:encoded>
      <description><![CDATA[介绍了为什么在 Nuxt 项目中，永远优先使用 <NuxtImg> 而不是原生 <img>。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Performance]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:relation><![CDATA[series:performance]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 集成 Drizzle ORM (PostgreSQL) 完整教程]]></title>
      <link>https://moongate.top/docs/nuxt-drizzle-postgresql</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-drizzle-postgresql</guid>
      <pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="适用版本">适用版本</h2>

<table>
<thead>
<tr>
<th>依赖</th>
<th>版本</th>
<th>备注</th>
</tr>
</thead>

<tbody>
<tr>
<td>Drizzle ORM</td>
<td><strong>v1.0.0-alpha.x</strong></td>
<td>本文基于 alpha.10，后续版本 API 可能微调</td>
</tr>

<tr>
<td>pg</td>
<td><strong>v8</strong></td>
<td>PostgreSQL 驱动</td>
</tr>
</tbody>
</table>

<blockquote>
<p>⚠️ <strong>注意</strong>：Drizzle ORM 目前仍处于 alpha 阶段，如果你使用更新版本，建议参考<a href="https://orm.drizzle.org.cn/" target="_blank">官方文档</a>。</p>
</blockquote>

<details>
<summary>📋教程范围与前置要求</summary>

本教程专注 **PostgreSQL + Nuxt 4** 的 Drizzle ORM 集成。如果您使用 MySQL/SQLite，驱动和类型会有差异，请参考官方文档相应部分。

### 前置要求

- 已有一个 Nuxt 4 项目（`npm create nuxt@latest <project-name>`）
- 本地已安装 PostgreSQL（或使用云数据库）
- 了解 TypeScript 基础

</details>

<hr>

<h2 id="核心区别-drizzle-官方文档-vs-nuxt-集成">⚠️ 核心区别：Drizzle 官方文档 vs Nuxt 集成</h2>

<table>
<thead>
<tr>
<th>对比维度</th>
<th>Drizzle 官方文档</th>
<th>本教程（Nuxt 4）</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>项目类型</strong></td>
<td>普通 Node.js 项目</td>
<td>Nuxt 4（基于 Nitro 服务器）</td>
</tr>

<tr>
<td><strong>目录结构</strong></td>
<td>自由定义</td>
<td>严格遵循 <code>server/</code> 目录规范</td>
</tr>

<tr>
<td><strong>数据库连接</strong></td>
<td>直接导出 <code>db</code> 实例</td>
<td>通过 <code>server/db/index</code> 导出 <code>useDB()</code></td>
</tr>

<tr>
<td><strong>Schema 组织</strong></td>
<td>通常单个文件</td>
<td>建议拆分多文件，并<strong>必须包含关系定义</strong></td>
</tr>

<tr>
<td><strong>运行环境</strong></td>
<td>手动执行脚本</td>
<td>通过 API 路由触发，由 Nitro 管理</td>
</tr>
</tbody>
</table>

<h3 id="关键点">关键点</h3>

<p><strong>完全照搬官方文档会在 Nuxt 中失败</strong>，因为 Nuxt 的服务端目录结构和自动导入机制与普通 Node 项目不同。</p>

<hr>

<h2 id="第一步-安装依赖">📦 第一步：安装依赖</h2>

<pre><code class="language-bash"># 生产依赖
pnpm add drizzle-orm pg
# 开发依赖
pnpm add -D drizzle-kit @types/pg dotenv
</code></pre>

<table>
<thead>
<tr>
<th>包名</th>
<th>作用</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>drizzle-orm</code></td>
<td>ORM 核心，提供类型安全的查询</td>
</tr>

<tr>
<td><code>pg</code></td>
<td>PostgreSQL 驱动</td>
</tr>

<tr>
<td><code>drizzle-kit</code></td>
<td>迁移工具，自动生成 SQL</td>
</tr>

<tr>
<td><code>@types/pg</code></td>
<td>TypeScript 类型（用于 pg）</td>
</tr>

<tr>
<td><code>dotenv</code></td>
<td>开发时从 <code>.env</code> 加载环境变量</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第二步-环境变量与配置">🔐 第二步：环境变量与配置</h2>

<h3 id="1-创建-env-文件-必须加入-gitignore">1. 创建 <code>.env</code> 文件（<strong>必须加入 <code>.gitignore</code></strong>）</h3>

<pre><code class="language-env"># .env
NUXT_DATABASE_URL=postgresql://postgres:yourpassword@localhost:5432/yourdb
</code></pre>

<blockquote>
<p>确保 URL 格式正确：<code>postgresql://用户名:密码@主机:端口/数据库名</code></p>
</blockquote>

<h3 id="2-配置-nuxt-config-ts">2. 配置 <code>nuxt.config.ts</code></h3>

<pre><code class="language-ts">export default defineNuxtConfig({
  runtimeConfig: {
    databaseUrl: process.env.NUXT_DATABASE_URL, // 无默认值，强制从环境变量读取
  },
  // ... 其他配置
})
</code></pre>

<blockquote>
<p><strong>重要</strong>：<code>databaseUrl</code> 必须从环境变量读取，不留默认值，避免生产环境误连本地数据库。</p>
</blockquote>

<hr>

<h2 id="第三步-组织-schema-文件-核心">📁 第三步：组织 Schema 文件（核心）</h2>

<p>Nuxt 项目中，建议将所有数据库相关文件放在 <code>server/db/</code> 下。<strong>必须同时包含表定义和关系定义</strong>。</p>

<h3 id="目录结构">目录结构</h3>

<pre><code class="language-text">server/
├── db/
│   ├── schema/
│   │   ├── users.ts
│   │   ├── comments.ts
│   │   ├── relations.ts
│   │   └── index.ts          # 统一导出
│   └── index.ts              # 数据库连接
</code></pre>

<h3 id="3-1-定义表-以-users-和-comments-为例">3.1 定义表（以 users 和 comments 为例）</h3>

<h4 id="server-db-schema-users-ts"><code>server/db/schema/users.ts</code></h4>

<pre><code class="language-ts">import {
  pgTable,
  serial,
  varchar,
  boolean,
  timestamp,
} from &quot;drizzle-orm/pg-core&quot;

export const users = pgTable(&quot;users&quot;, {
  id: serial(&quot;id&quot;).primaryKey(),
  githubId: varchar(&quot;github_id&quot;, { length: 39 }).notNull().unique(),
  username: varchar(&quot;username&quot;, { length: 100 }).notNull(),
  isAdmin: boolean(&quot;is_admin&quot;).default(false),
  createdAt: timestamp(&quot;created_at&quot;, { withTimezone: true }).defaultNow(),
})

export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
</code></pre>

<h4 id="server-db-schema-comments-ts"><code>server/db/schema/comments.ts</code></h4>

<pre><code class="language-ts">import {
  pgTable,
  serial,
  integer,
  text,
  varchar,
  timestamp,
} from &quot;drizzle-orm/pg-core&quot;
import { users } from &quot;./users&quot;

export const comments = pgTable(&quot;comments&quot;, {
  id: serial(&quot;id&quot;).primaryKey(),
  userId: integer(&quot;user_id&quot;).references(() =&gt; users.id, {
    onDelete: &quot;set null&quot;,
  }),
  content: text(&quot;content&quot;).notNull(),
  permalink: varchar(&quot;permalink&quot;, { length: 255 }).notNull(),
  parentId: integer(&quot;parent_id&quot;).references((): any =&gt; comments.id, {
    onDelete: &quot;cascade&quot;,
  }),
  createdAt: timestamp(&quot;created_at&quot;, { withTimezone: true }).defaultNow(),
})

export type Comment = typeof comments.$inferSelect
export type NewComment = typeof comments.$inferInsert
</code></pre>

<h3 id="3-2-定义关系-relations">3.2 定义关系（relations）</h3>

<h4 id="server-db-schema-relations-ts"><code>server/db/schema/relations.ts</code></h4>

<pre><code class="language-ts">import { relations } from &quot;drizzle-orm&quot;
import { users } from &quot;./users&quot;
import { comments } from &quot;./comments&quot;

// 评论 -&gt; 用户（多对一）
export const commentsRelations = relations(comments, ({ one }) =&gt; ({
  user: one(users, {
    fields: [comments.userId],
    references: [users.id],
  }),
}))

// 用户 -&gt; 评论（一对多）
export const usersRelations = relations(users, ({ many }) =&gt; ({
  comments: many(comments),
}))
</code></pre>

<h3 id="3-3-统一导出">3.3 统一导出</h3>

<h4 id="server-db-schema-index-ts"><code>server/db/schema/index.ts</code></h4>

<pre><code class="language-ts">export * from &quot;./users&quot;
export * from &quot;./comments&quot;
export * from &quot;./relations&quot;
</code></pre>

<hr>

<h2 id="第四步-创建数据库连接工具">🔌 第四步：创建数据库连接工具</h2>

<p>Nuxt 中，数据库连接应放在 <code>server/db/</code> 下以便导入。</p>

<h3 id="server-db-ts"><code>server/db.ts</code></h3>

<pre><code class="language-ts">import { drizzle } from &quot;drizzle-orm/node-postgres&quot;
import { Pool } from &quot;pg&quot;
import * as schema from &quot;../db/schema&quot; // 导入完整的 schema

const config = useRuntimeConfig()

const pool = new Pool({
  connectionString: config.databaseUrl,
})

// 导出函数，每次调用获取新连接（防止连接泄漏）
export const useDB = () =&gt; drizzle(pool, { schema })
</code></pre>

<blockquote>
<p><strong>注意</strong>：必须传入完整的 <code>schema</code> 对象（包含表和关系），否则无法使用 <code>with</code> 等关系查询。</p>
</blockquote>

<hr>

<h2 id="第五步-验证配置">🧪 第五步：验证配置</h2>

<p>创建测试 API 路由，确保一切正常。</p>

<h3 id="server-api-test-db-get-ts"><code>server/api/test/db.get.ts</code></h3>

<pre><code class="language-ts">import { sql } from &quot;drizzle-orm&quot;

export default defineEventHandler(async (event) =&gt; {
  try {
    const db = useDB()
    const result = await db.execute(sql`SELECT 1+1 as result`)
    return { success: true, data: result.rows[0] }
  } catch (error) {
    console.error(&quot;DB connection failed:&quot;, error)
    return { success: false, error: String(error) }
  }
})
</code></pre>

<p>访问 <code>http://localhost:3000/api/test/db</code>，若返回 <code>{ result: 2 }</code> 则连接成功。</p>

<hr>

<h2 id="第六步-数据库迁移">📜 第六步：数据库迁移</h2>

<h3 id="6-1-配置-drizzle-config-ts">6.1 配置 <code>drizzle.config.ts</code></h3>

<p>在项目根目录创建：</p>

<pre><code class="language-ts">import &quot;dotenv/config&quot;
import { defineConfig } from &quot;drizzle-kit&quot;

export default defineConfig({
  out: &quot;./server/db/migrations&quot;,
  schema: &quot;./server/db/schema/index.ts&quot;, // 指向统一导出文件
  dialect: &quot;postgresql&quot;,
  dbCredentials: {
    url: process.env.NUXT_DATABASE_URL!,
  },
})
</code></pre>

<h3 id="6-2-生成迁移文件">6.2 生成迁移文件</h3>

<pre><code class="language-bash">npx drizzle-kit generate
</code></pre>

<p>这会在 <code>server/db/migrations</code> 生成 SQL 文件。</p>

<h3 id="6-3-执行迁移">6.3 执行迁移</h3>

<pre><code class="language-bash">npx drizzle-kit migrate
</code></pre>

<p><strong>开发环境</strong>也可以直接用 <code>push</code> 快速同步：</p>

<pre><code class="language-bash">npx drizzle-kit push
</code></pre>

<blockquote>
<p><strong>生产环境</strong>：必须使用 <code>generate</code> + <code>migrate</code>，并将生成的 SQL 文件纳入版本控制，以便回滚和审核。</p>
</blockquote>

<hr>

<h2 id="第七步-在-api-中使用-drizzle">🛠️ 第七步：在 API 中使用 Drizzle</h2>

<h3 id="查询示例-带关系">查询示例（带关系）</h3>

<h4 id="server-api-comments-get-ts"><code>server/api/comments.get.ts</code></h4>

<pre><code class="language-ts">import { comments } from &quot;~/server/db/schema&quot; // 需要显式导入表定义
import { eq, desc } from &quot;drizzle-orm&quot;

export default defineEventHandler(async (event) =&gt; {
  const query = getQuery(event)
  const permalink = query.permalink as string

  const db = useDB()
  const result = await db.query.comments.findMany({
    where: eq(comments.permalink, permalink),
    orderBy: [desc(comments.createdAt)],
    with: {
      user: {
        columns: { username: true },
      },
    },
  })

  return { success: true, data: result }
})
</code></pre>

<h3 id="插入示例">插入示例</h3>

<h4 id="server-api-comments-post-ts"><code>server/api/comments.post.ts</code></h4>

<pre><code class="language-ts">import { comments, type NewComment } from &quot;~/server/db/schema&quot;
import { useDB } from &quot;~~/server/db&quot;

export default defineEventHandler(async (event) =&gt; {
  const body = await readBody(event)
  const db = useDB()

  const newComment: NewComment = {
    userId: body.userId,
    content: body.content,
    permalink: body.permalink,
  }

  const [inserted] = await db.insert(comments).values(newComment).returning()
  return { success: true, data: inserted }
})
</code></pre>

<hr>

<h2 id="常见错误与解决方案">🐛 常见错误与解决方案</h2>

<h3 id="错误-1-cannot-read-properties-of-undefined-reading-referencedtable">错误 1：<code>Cannot read properties of undefined (reading 'referencedTable')</code></h3>

<p><strong>原因</strong>：初始化 <code>drizzle</code> 时没有传入完整 schema（缺少 relations）。</p>

<p><strong>解决</strong>：确保 <code>useDB()</code> 中 <code>drizzle(pool, { schema })</code> 的 <code>schema</code> 对象包含了 <code>relations</code> 导出。</p>

<h3 id="错误-2-usedb-未定义">错误 2：<code>useDB()</code> 未定义</h3>

<p><strong>原因</strong>：文件未放在 <code>server/utils/</code> 下，或 Nuxt 自动导入失效（需重启 dev）。</p>

<p><strong>解决</strong>：检查文件路径，重启 <code>pnpm dev</code>。</p>

<h3 id="错误-3-db-query-comments-findmany-不存在">错误 3：<code>db.query.comments.findMany</code> 不存在</h3>

<p><strong>原因</strong>：没有启用 Drizzle 的关系查询 API，需要传入 schema 并确保 <code>drizzle-orm</code> 版本支持。</p>

<p><strong>解决</strong>：确认 <code>useDB()</code> 返回的是带有 <code>query</code> 属性的实例（即传入了 schema）。</p>

<h3 id="错误-4-迁移时找不到表">错误 4：迁移时找不到表</h3>

<p><strong>原因</strong>：</p>

<p><code>drizzle.config.ts</code> 中的 <code>schema</code> 路径错误，或指向的文件没有导出所有表。
<strong>解决</strong>：确保路径正确，且 <code>schema/index.ts</code> 导出了所有表。</p>

<h3 id="错误-5-生产环境数据库连接失败">错误 5：生产环境数据库连接失败</h3>

<p><strong>原因</strong>：环境变量未正确设置，或连接字符串格式错误。</p>

<p><strong>解决</strong>：在服务器上检查 <code>NUXT_DATABASE_URL</code> 是否正确，并确保网络可达。</p>

<hr>

<h2 id="最佳实践总结">💡 最佳实践总结</h2>

<ol>
<li><strong>永远不要提交 <code>.env</code></strong>。</li>
<li><strong>schema 必须包含 relations</strong>，否则无法使用 <code>with</code> 查询。</li>
<li><strong>数据库连接函数放在 <code>server/utils/</code></strong>，利用 Nuxt 自动导入。</li>
<li><strong>在 API 中显式导入表定义</strong>（如 <code>import { users } from '~/server/db/schema'</code>）。</li>
<li><strong>生产环境使用迁移文件</strong>，禁止用 <code>push</code>。</li>
<li><strong>测试环境与开发环境分离</strong>，用不同的数据库 URL。</li>
</ol>

<hr>

<h2 id="最终验证">🎯 最终验证</h2>

<p>完成以上步骤后，你应该能够：</p>

<ul>
<li>通过 <code>pnpm dev</code> 启动项目，访问测试 API 得到 <code>{ result: 2 }</code></li>
<li>使用 <code>drizzle-kit generate/migrate</code> 管理数据库变更</li>
<li>在 API 中正确查询带关联的数据</li>
<li>在生产环境中通过环境变量连接数据库</li>
</ul>

<p>如果遇到任何问题，请对照每一步仔细检查。记住：<strong>Drizzle 的官方文档是通用指南，Nuxt 集成需要根据其目录结构和自动导入机制进行调整</strong>。这篇教程已为你铺平道路，祝你顺利！</p>
]]></content:encoded>
      <description><![CDATA[本教程专注于 Drizzle ORM 在 PostgreSQL 数据库上的 Nuxt 4 集成。如果您使用的是 MySQL、SQLite 等其他数据库，部分配置（如连接驱动、数据类型）会有所不同，请参考 Drizzle 官方文档相应部分。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[PostgreSQL]]></category>
      <category><![CDATA[ORM]]></category>
      <dc:relation><![CDATA[series:backend]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 集成 GitHub 登录：从原理到实践（开发 + 生产环境完整版）]]></title>
      <link>https://moongate.top/docs/nuxt-oauth-github</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-oauth-github</guid>
      <pubDate>Sun, 15 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-oauth-2-0-核心原理-为什么第三方登录能-认识-用户">一、OAuth 2.0 核心原理（为什么第三方登录能“认识”用户）</h2>

<h3 id="1-1-四个角色">1.1 四个角色</h3>

<p>OAuth 流程涉及四个参与者：</p>

<table>
<thead>
<tr>
<th>角色</th>
<th>技术名词</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>资源所有者</strong></td>
<td>Resource Owner</td>
<td>拥有 GitHub 账号的用户</td>
</tr>

<tr>
<td><strong>客户端应用</strong></td>
<td>Client</td>
<td>需要访问用户 GitHub 信息的应用（即你的 Nuxt 应用）</td>
</tr>

<tr>
<td><strong>授权服务器</strong></td>
<td>Authorization Server</td>
<td>GitHub 的身份验证与授权端点</td>
</tr>

<tr>
<td><strong>资源服务器</strong></td>
<td>Resource Server</td>
<td>GitHub 的 API 服务器，存储用户数据</td>
</tr>
</tbody>
</table>
<p>在 GitHub 的实现中，授权服务器与资源服务器属同一实体，但逻辑职责分离。</p>

<h3 id="1-2-授权码模式核心流程">1.2 授权码模式核心流程</h3>

<p>OAuth 2.0 授权码模式是最安全的流程，核心思想是：客户端应用<strong>绝不接触用户密码</strong>，而是通过一次性的授权码换取代表用户身份的访问令牌。</p>

<ol>
<li><strong>引导用户</strong>：应用将用户重定向到 GitHub 授权页，附带 <code>client_id</code>、<code>redirect_uri</code> 和 <code>state</code>。</li>
<li><strong>用户授权</strong>：用户在 GitHub 登录并确认授权。</li>
<li><strong>返回授权码</strong>：GitHub 将用户重定向回应用的回调地址，并在 URL 中附带授权码。</li>
<li><strong>换取令牌</strong>：应用后端使用 <code>client_id</code> + <code>client_secret</code> + 授权码向 GitHub 换取 <code>access_token</code>。</li>
<li><strong>获取用户信息</strong>：后端使用 <code>access_token</code> 调用 GitHub API 获取用户数据。</li>
</ol>

<h3 id="1-3-关键概念">1.3 关键概念</h3>

<ul>
<li><strong>client_id</strong>：应用的公开标识，用于识别应用。</li>
<li><strong>client_secret</strong>：应用的私密密钥，用于后端安全通信，<strong>严禁暴露</strong>。</li>
<li><strong>redirect_uri</strong>：授权成功后 GitHub 重定向的地址，必须与注册时完全一致。</li>
<li><strong>scope</strong>：权限范围，指定应用可访问的用户信息（如公开资料、邮箱等）。</li>
<li><strong>state</strong>：防 CSRF 的随机字符串，在请求和回调中保持一致。</li>
</ul>

<hr>

<h2 id="二-github-oauth-完整交互时序">二、GitHub OAuth 完整交互时序</h2>

<pre><code class="language-mermaid">sequenceDiagram
participant 用户 as 用户 (浏览器)
participant 前端 as 应用前端 (Nuxt)
participant 后端 as 应用后端 (Nuxt Server)
participant GitHubAuth as GitHub 授权服务器
participant GitHubAPI as GitHub 资源服务器
用户-&gt;&gt;前端: 1. 点击“GitHub登录”
前端-&gt;&gt;后端: 2. 跳转到 /api/auth/github
后端-&gt;&gt;GitHubAuth: 3. 302重定向到 GitHub (带 client_id, redirect_uri, state)
GitHubAuth--&gt;&gt;用户: 4. 显示授权页面
用户-&gt;&gt;GitHubAuth: 5. 登录GitHub账号并点击“Authorize”
GitHubAuth-&gt;&gt;后端: 6. 302重定向回调地址 (带授权码 &amp; state)
后端-&gt;&gt;GitHubAuth: 7. 用授权码 + client_secret 请求 Access Token
GitHubAuth--&gt;&gt;后端: 8. 返回 Access Token
后端-&gt;&gt;GitHubAPI: 9. 用 Access Token 请求用户信息 (GET /user)
GitHubAPI--&gt;&gt;后端: 10. 返回用户数据 (id, login, avatar_url...)
后端-&gt;&gt;后端: 11. 用 session 密码加密用户数据，存入 Cookie
后端-&gt;&gt;用户: 12. 302重定向回首页 (附带加密 Cookie)
用户-&gt;&gt;前端: 13. 访问首页，浏览器自动携带 Cookie
前端-&gt;&gt;后端: 14. Vue组件通过 useUserSession() 请求 /api/_auth/session
后端-&gt;&gt;后端: 15. 解密 Cookie，验证身份
后端--&gt;&gt;前端: 16. 返回用户数据
前端--&gt;&gt;用户: 17. 页面显示“欢迎，用户名”
</code></pre>

<h3 id="各步骤原理">各步骤原理</h3>

<ul>
<li><strong>步骤 2</strong>：<code>/api/auth/github</code> 由 <code>nuxt-auth-utils</code> 提供，构造 GitHub 授权 URL 并返回 302 重定向。</li>
<li><strong>步骤 3</strong>：重定向 URL 包含 <code>client_id</code>、<code>redirect_uri</code> 和自动生成的 <code>state</code>。</li>
<li><strong>步骤 6</strong>：回调中携带授权码和 <code>state</code>，后端验证 <code>state</code> 一致性。</li>
<li><strong>步骤 7</strong>：后端通过 <code>client_secret</code> 换取 <code>access_token</code>，该步骤在服务器间进行，密钥不暴露。</li>
<li><strong>步骤 11</strong>：使用 <code>NUXT_SESSION_PASSWORD</code> 加密用户数据，存入 <code>HttpOnly</code> 的 <code>nuxt-session</code> Cookie。</li>
<li><strong>步骤 14</strong>：<code>useUserSession()</code> 实际调用 <code>/api/_auth/session</code>，后端解密 Cookie 返回用户信息。</li>
</ul>

<hr>

<h2 id="三-nuxt-auth-utils-工作原理">三、nuxt-auth-utils 工作原理</h2>

<h3 id="3-1-session-存储-加密-cookie">3.1 Session 存储：加密 Cookie</h3>

<ul>
<li>调用 <code>setUserSession(event, data)</code> 时，模块利用 <code>NUXT_SESSION_PASSWORD</code> 对数据进行加密，生成 <code>nuxt-session</code> Cookie。</li>
<li>Cookie 属性：<code>HttpOnly</code>（防 XSS）、<code>SameSite=Lax</code>（防 CSRF）、<code>Secure</code>（生产环境强制 HTTPS）。</li>
<li>后续请求自动携带该 Cookie，后端通过 <code>getUserSession(event)</code> 解密还原数据。</li>
</ul>

<h4 id="优点">优点</h4>

<p>无需数据库，适合 Serverless 部署；数据加密防篡改。</p>

<h4 id="缺点">缺点</h4>

<p>Cookie 大小限制 4KB；无法主动全局使所有 session 失效。</p>

<h3 id="3-2-前端-useusersession">3.2 前端 useUserSession</h3>

<ul>
<li>组件挂载时自动调用 <code>/api/_auth/session</code> 获取当前用户数据。</li>
<li>返回 <code>loggedIn</code>（计算属性，等价于 <code>!!user.value</code>）和 <code>user</code>（响应式数据）。</li>
<li>登录状态变化时自动更新。</li>
</ul>

<h3 id="3-3-为何区分-loggedin-和-user">3.3 为何区分 <code>loggedIn</code> 和 <code>user</code></h3>

<ul>
<li>模板中可直接用 <code>v-if=&quot;loggedIn&quot;</code> 表达登录状态，避免手动判断 <code>user</code> 是否为空。</li>
</ul>

<hr>

<h2 id="四-开发环境配置-本地运行">四、开发环境配置（本地运行）</h2>

<h3 id="4-1-安装-nuxt-auth-utils-模块">4.1 安装 nuxt-auth-utils 模块</h3>

<pre><code class="language-bash">npx nuxi@latest module add auth-utils
</code></pre>

<h3 id="4-2-环境变量配置-使用-env-example-模板">4.2 环境变量配置：使用 <code>.env.example</code> 模板</h3>

<ol>
<li>在项目根目录创建 <code>.env.example</code> 文件，并<strong>提交到代码仓库</strong>：</li>
</ol>

<pre><code class="language-bash">   # 至少 32 位随机字符串（本地开发用）
   NUXT_SESSION_PASSWORD=your-local-32-char-dev-password
   # GitHub OAuth 凭证（需创建独立的 GitHub OAuth App）
   NUXT_OAUTH_GITHUB_CLIENT_ID=your_dev_app_client_id
   NUXT_OAUTH_GITHUB_CLIENT_SECRET=your_dev_app_client_secret
</code></pre>

<ol>
<li><strong>本地开发时</strong>，复制一份为 <code>.env</code> 并填入真实值：</li>
</ol>

<pre><code class="language-bash">   cp .env.example .env
</code></pre>

<p><strong>并将 <code>.env</code> 添加到 <code>.gitignore</code></strong>（确保不会误提交）：</p>

<pre><code class="language-bash">   .env
</code></pre>

<p><strong>原理</strong>：</p>

<p><code>.env.example</code> 作为文档，告诉其他开发者需要哪些环境变量；<code>.env</code> 包含真实敏感信息，仅存在于本地。</p>

<h3 id="4-3-在-github-创建开发环境的-oauth-app">4.3 在 GitHub 创建开发环境的 OAuth App</h3>

<ol>
<li><p>登录 GitHub → <strong>Settings</strong> → <strong>Developer settings</strong> → <strong>OAuth Apps</strong> → <strong>New OAuth App</strong>。</p></li>

<li><p>填写：</p>

<ul>
<li><strong>Application name</strong>：例如 <code>myapp-dev</code>（明确标识为开发环境）</li>
<li><strong>Homepage URL</strong>：<code>http://localhost:3000</code></li>
<li><strong>Authorization callback URL</strong>：<code>http://localhost:3000/api/auth/github</code></li>
</ul></li>

<li><p>注册后复制 <strong>Client ID</strong> 和 <strong>Client Secret</strong> 填入 <code>.env</code> 文件。</p></li>
</ol>

<h3 id="4-4-配置-nuxt-config-ts">4.4 配置 nuxt.config.ts</h3>

<pre><code class="language-ts">export default defineNuxtConfig({
  modules: [&quot;nuxt-auth-utils&quot;],
  runtimeConfig: {
    oauth: {
      github: {
        clientId: &quot;&quot;, // 留空，由环境变量注入
        clientSecret: &quot;&quot;,
      },
    },
  },
})
</code></pre>

<p><strong>原理</strong>：</p>

<p><code>runtimeConfig</code> 自动将 <code>NUXT_OAUTH_GITHUB_*</code> 注入对应字段，无需硬编码。</p>

<h3 id="4-5-创建服务端路由处理回调">4.5 ### 创建服务端路由处理回调</h3>

<p>创建<code>server/api/auth/github.get.ts</code>：</p>

<pre><code class="language-ts">export default defineOAuthGitHubEventHandler({
  // 使用模块内置的 GitHub OAuth 处理器
  async onSuccess(event, { user }) {
    await setUserSession(event, {
      user: {
        githubId: String(user.id),
        login: user.login,
        name: user.name,
        avatarUrl: user.avatar_url,
        email: user.email, // 注意：可能为 null
      },
      loggedInAt: Date.now(),
    })

    // 重定向回首页（或来源页）
    return sendRedirect(event, &quot;/&quot;)
  },
  onError(event, error) {
    console.error(&quot;GitHub OAuth error:&quot;, error)
    return sendRedirect(event, &quot;/?auth_error=true&quot;)
  },
})
</code></pre>

<h4 id="注意">注意</h4>

<p>以上代码使用了 <code>requireNuxtAuthSession</code>，但实际 <code>nuxt-auth-utils</code> 模块的 API 可能略有不同，请以<a href="https://github.com/Atinux/nuxt-auth-utils" target="_blank">官方文档</a>为准。如果模块提供了专门的 <code>defineOAuthGitHubEventHandler</code>，建议直接使用。</p>

<p><strong>原理</strong>：</p>

<p><code>defineOAuthGitHubEventHandler</code> 封装了授权码交换和 token 获取；<code>setUserSession</code> 加密数据存入 Cookie。</p>

<h3 id="4-6-前端登录按钮">4.6 前端登录按钮</h3>

<pre><code class="language-vue">&lt;script setup&gt;
const { loggedIn, user, clear } = useUserSession()
const loginWithGitHub = () =&gt; {
  // 跳转到 GitHub 授权页
  navigateTo(&quot;/api/auth/github&quot;, { external: true })
}
&lt;/script&gt;
&lt;template&gt;
  &lt;div&gt;
    &lt;div v-if=&quot;loggedIn&quot;&gt;
      &lt;img :src=&quot;user.avatarUrl&quot; class=&quot;w-8 h-8 rounded-full&quot; /&gt;
      &lt;span&gt;{{ user.name || user.login }}&lt;/span&gt;
      &lt;button @click=&quot;clear&quot;&gt;登出&lt;/button&gt;
    &lt;/div&gt;
    &lt;button v-else @click=&quot;loginWithGitHub&quot;&gt;GitHub 登录&lt;/button&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p><strong>原理</strong>：</p>

<p><code>useUserSession</code> 自动获取用户信息；<code>navigateTo(..., { external: true })</code> 触发外部重定向；<code>clear()</code> 清除 session Cookie。</p>

<h3 id="4-7-启动开发服务器">4.7 启动开发服务器</h3>

<pre><code class="language-bash">pnpm dev
</code></pre>

<p>访问 <code>http://localhost:3000</code>，点击登录应能正常跳转到 GitHub 授权页。</p>

<h2 id="五-进阶功能-登录后重定向回来源页">五、进阶功能：登录后重定向回来源页</h2>

<h3 id="5-1-问题的由来">5.1 问题的由来</h3>

<p>默认实现中，登录成功后用户被重定向到首页 /。但更符合用户体验的做法是：用户从哪个页面点击登录，登录后就应该回到哪个页面。例如从 /en/docs/123 点击登录，成功后应回到同一页面。</p>

<h3 id="5-2-技术难点-github-回调会丢弃自定义参数">5.2 技术难点：GitHub 回调会丢弃自定义参数</h3>

<p>GitHub 在回调时只会保留 code 和 state 两个参数，你附加的任何自定义查询参数（如 ?redirect=/en/docs）都会被丢弃。因此无法通过 URL 参数直接传递来源页。</p>

<h3 id="5-3-解决方案-使用-session-存储来源页">5.3 解决方案：使用 session 存储来源页</h3>

<h4 id="5-3-1-创建存储来源页的-api">5.3.1 创建存储来源页的 API</h4>

<pre><code class="language-ts">// server/api/store-redirect.post.ts
export default defineEventHandler(async (event) =&gt; {
  const { redirect } = await readBody(event)

  // 校验 redirect 是否为内部路径（防止开放重定向）
  if (!redirect || typeof redirect !== &quot;string&quot; || !redirect.startsWith(&quot;/&quot;)) {
    return { ok: false }
  }

  await setUserSession(event, { redirect })
  return { ok: true }
})
</code></pre>

<p>如果你担心有人恶意传 /\/\/evil.com 这种试图绕过检测的路径，可以加一个更严格的 URL 解析校验：</p>

<pre><code class="language-ts">import { parseURL } from &quot;ufo&quot;

const { redirect } = await readBody(event)

// 解析路径，确保是内部路径
const parsed = parseURL(redirect)
if (!redirect || !parsed.pathname || parsed.host) {
  return { ok: false }
}
</code></pre>

<blockquote>
<p>但说实话，startsWith(&ldquo;/&rdquo;) 对 99.9% 的场景已经足够。这个补充纯属过度设计，不搞也行。</p>
</blockquote>

<h4 id="5-3-2-修改前端登录函数">5.3.2 修改前端登录函数</h4>

<pre><code class="language-vue">&lt;script setup&gt;
const { loggedIn } = useUserSession()
const route = useRoute()

const loginWithGitHub = async () =&gt; {
  // 将当前完整路径保存到 session
  await $fetch(&quot;/api/store-redirect&quot;, {
    method: &quot;POST&quot;,
    body: { redirect: route.fullPath },
  })
  navigateTo(&quot;/api/auth/github&quot;, { external: true })
}
&lt;/script&gt;
</code></pre>

<h4 id="5-3-3-修改回调路由以读取来源页">5.3.3 修改回调路由以读取来源页</h4>

<pre><code class="language-ts">// server/api/auth/github.get.ts
export default defineOAuthGitHubEventHandler({
  async onSuccess(event, { user }) {
    // 获取之前存储的来源页
    const session = await getUserSession(event)
    let redirect = (session.redirect as string) || &quot;/&quot;

    // 清理 session 中的 redirect（避免下次重复使用）
    await setUserSession(event, { ...session, redirect: undefined })

    // 存入用户信息
    await setUserSession(event, {
      user: {
        githubId: String(user.id),
        login: user.login,
        name: user.name,
        avatarUrl: user.avatar_url,
        email: user.email,
      },
      loggedInAt: Date.now(),
    })

    // 重定向回来源页
    return sendRedirect(event, redirect)
  },

  async onError(event, error) {
    console.error(&quot;GitHub OAuth error:&quot;, error)
    return sendRedirect(event, &quot;/login?error=true&quot;)
  },
})
</code></pre>

<h5 id="安全说明">安全说明</h5>

<p>在 store-redirect.post.ts 中增加了路径校验，防止开放重定向漏洞。</p>

<h3 id="5-4-水合问题的处理">5.4 水合问题的处理</h3>

<p>当网站支持国际化路径前缀（如 <code>/zh</code>、<code>/en</code>）时，登录后重定向到来源页还能<strong>自动解决水合不匹配问题</strong>。</p>

<h4 id="5-4-1-水合不匹配的产生原因">5.4.1 水合不匹配的产生原因</h4>

<ol>
<li>用户在 <code>/en/docs/123</code> 点击登录</li>
<li>登录成功后，若直接重定向到首页 <code>/</code>（无语言前缀）</li>
<li>服务器返回的是默认语言（如中文）的 HTML</li>
<li>但客户端期望的 hydration 内容是英文（用户来源页面是 <code>/en/...</code>）</li>
<li>导致水合警告：<code>Hydration completed but contains mismatches</code></li>
</ol>

<h4 id="5-4-2-为什么重定向到来源页能解决">5.4.2 为什么重定向到来源页能解决</h4>

<ul>
<li>用户从 <code>/en/docs/123</code> 来，登录后回到 <code>/en/docs/123</code></li>
<li>服务器根据 URL 中的 <code>/en</code> 前缀渲染对应的<strong>英文版本</strong></li>
<li>客户端 hydration 时，DOM 结构与 URL 完全匹配</li>
<li>水合警告自然消失</li>
</ul>

<hr>

<h2 id="六-生产环境配置-服务器部署">六、生产环境配置（服务器部署）</h2>

<h3 id="6-1-核心差异-环境变量来源">6.1 核心差异：环境变量来源</h3>

<table>
<thead>
<tr>
<th>环境</th>
<th>配置文件</th>
<th>变量来源</th>
</tr>
</thead>

<tbody>
<tr>
<td>开发环境</td>
<td><code>.env</code> 文件</td>
<td>本地文件（已 gitignore）</td>
</tr>

<tr>
<td>生产环境</td>
<td><strong>无</strong> <code>.env</code> 文件</td>
<td>系统环境变量 / 托管平台 Secrets</td>
</tr>
</tbody>
</table>

<h4 id="nuxt-4-的设计原则">Nuxt 4 的设计原则</h4>

<p>生产环境不读取 <code>.env</code> 文件，所有环境变量必须通过运行环境提供（如 Vercel/Netlify 的环境变量面板、Linux 系统环境变量、Docker 环境变量等）。
<strong>千万不要将 <code>.env</code> 文件上传到服务器</strong>，也不要在构建过程中打包进去。</p>

<h3 id="6-2-创建生产环境的-github-oauth-app">6.2 创建生产环境的 GitHub OAuth App</h3>

<ol>
<li><p>登录 GitHub → 创建<strong>另一个</strong> OAuth App（与开发环境分开）。</p></li>

<li><p>填写：</p>

<ul>
<li><strong>Application name</strong>：例如 <code>myapp-prod</code></li>
<li><strong>Homepage URL</strong>：<code>https://你的域名</code></li>
<li><strong>Authorization callback URL</strong>：<code>https://你的域名/api/auth/github</code></li>
</ul></li>

<li><p>生成并保存 <strong>Client ID</strong> 和 <strong>Client Secret</strong>。</p></li>
</ol>

<h3 id="6-3-服务器环境变量配置-以-linux-pm2-为例">6.3 服务器环境变量配置（以 Linux + PM2 为例）</h3>

<h4 id="6-3-1-直接设置系统环境变量-临时方案">6.3.1 直接设置系统环境变量（临时方案）</h4>

<pre><code class="language-bash"># 编辑 /etc/profile 或 ~/.bashrc，添加：
export NUXT_SESSION_PASSWORD=your-production-32-char-password
export NUXT_OAUTH_GITHUB_CLIENT_ID=your_prod_client_id
export NUXT_OAUTH_GITHUB_CLIENT_SECRET=your_prod_client_secret
# 使环境变量生效
source ~/.bashrc
</code></pre>

<h4 id="6-3-2-在-pm2-配置文件中引用环境变量-推荐">6.3.2 在 PM2 配置文件中引用环境变量（<strong>推荐</strong>）</h4>

<p>创建 <code>ecosystem.config.js</code>（或通过 CI/CD 自动生成）：</p>

<pre><code class="language-javascript">module.exports = {
  apps: [
    {
      name: &quot;moongate&quot;,
      script: &quot;./server/index.mjs&quot;,
      instances: 1,
      exec_mode: &quot;fork&quot;,
      env: {
        NODE_ENV: &quot;production&quot;,
        NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;,
        PORT: 3000,
        HOST: &quot;0.0.0.0&quot;,
        // 引用系统环境变量（更安全）
        NUXT_SESSION_PASSWORD: &quot;your-production-32-char-password&quot;,
        NUXT_OAUTH_GITHUB_CLIENT_ID: &quot;your_prod_client_id&quot;,
        NUXT_OAUTH_GITHUB_CLIENT_SECRET: &quot;your_prod_client_secret&quot;,
      },
    },
  ],
}
</code></pre>

<h3 id="6-4-ci-cd-自动化部署-github-actions-示例">6.4 CI/CD 自动化部署（GitHub Actions 示例）</h3>

<pre><code class="language-yaml">name: Deploy To Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm build

      - name: Deploy via Rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avz --delete
          path: .output/
          remote_path: /var/www/my-site/
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: ${{ secrets.SERVER_USER }}
          remote_key: ${{ secrets.SSH_PRIVATE_KEY }}

      - name: Start service via SSH
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: NUXT_SESSION_PASSWORD, NUXT_OAUTH_GITHUB_CLIENT_ID, NUXT_OAUTH_GITHUB_CLIENT_SECRET
          script: |
            cd /var/www/my-site

            cat &gt; ecosystem.config.js &lt;&lt; EOF
            module.exports = {
              apps: [{
                name: &quot;moongate&quot;,
                script: &quot;./server/index.mjs&quot;,
                instances: 1,
                exec_mode: &quot;fork&quot;,
                env: {
                  NODE_ENV: &quot;production&quot;,
                  NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;,
                  PORT: 3000,
                  HOST: &quot;0.0.0.0&quot;,
                  NUXT_SESSION_PASSWORD: &quot;$(echo -n &quot;$NUXT_SESSION_PASSWORD&quot;)&quot;,
                  NUXT_OAUTH_GITHUB_CLIENT_ID: &quot;$(echo -n &quot;$NUXT_OAUTH_GITHUB_CLIENT_ID&quot;)&quot;,
                  NUXT_OAUTH_GITHUB_CLIENT_SECRET: &quot;$(echo -n &quot;$NUXT_OAUTH_GITHUB_CLIENT_SECRET&quot;)&quot;
                }
              }]
            }
            EOF

            pm2 restart ecosystem.config.js --update-env
</code></pre>

<h4 id="关键点">关键点</h4>

<p>使用 <code>echo -n</code> 去除换行符，避免密码末尾被污染导致加密不匹配。</p>

<h3 id="6-5-验证生产环境">6.5 验证生产环境</h3>

<ul>
<li>访问 <code>https://你的域名.com/api/_auth/session</code>，应返回 <code>{&quot;user&quot;:null}</code> 或登录后的用户信息。</li>
<li>点击登录按钮，应能跳转到 GitHub 授权页，授权后返回首页并显示用户信息。</li>
</ul>

<hr>

<h2 id="七-生产环境常见错误-附根本原因与解决方案">七、生产环境常见错误（附根本原因与解决方案）</h2>

<h3 id="7-1-静态资源-404-css-js-无法加载">7.1 静态资源 404（CSS/JS 无法加载）</h3>

<p><strong>现象</strong>：页面无样式，控制台大量 <code>.css</code>、<code>.js</code> 请求 404。</p>

<p><strong>原因</strong>：</p>

<ul>
<li>Nuxt 构建后的静态文件（<code>.output/public/</code>）未正确同步到服务器。</li>
<li>或 PM2 未重启，服务仍在旧代码路径下运行。<br>
<strong>解决</strong>：</li>
<li>确认 <code>rsync</code> 路径是否正确：本地 <code>.output/</code> → 远程 <code>/var/www/my-site/</code>。</li>
<li>重启 PM2：<code>pm2 restart &lt;app-name&gt; --update-env</code>。</li>
</ul>

<h3 id="7-2-api-auth-session-返回-500">7.2 <code>/api/_auth/session</code> 返回 500</h3>

<p><strong>现象</strong>：登录后无法获取用户信息，接口报错。</p>

<p><strong>原因</strong>：</p>

<ul>
<li><code>NUXT_SESSION_PASSWORD</code> 未正确传递，或<strong>值末尾包含换行符</strong>（常见于 GitHub Actions 中直接 <code>echo</code> 变量）。</li>
<li><code>NUXT_SESSION_PASSWORD</code> 长度不足 32 位。<br>
<strong>解决</strong>：</li>
<li>使用 <code>echo -n</code> 去除换行符（见上方 CI/CD 示例）。</li>
<li>检查配置文件中的密码值是否干净。</li>
</ul>

<h3 id="7-3-oauth-回调成功但页面未登录">7.3 OAuth 回调成功但页面未登录</h3>

<p><strong>现象</strong>：GitHub 跳转回首页，但右上角仍显示“登录”。</p>

<p><strong>原因</strong>：</p>

<ul>
<li><code>setUserSession</code> 未执行（回调路由有误）。</li>
<li><code>NUXT_SESSION_PASSWORD</code> 与开发环境不一致，导致 Cookie 无法解密。<br>
<strong>解决</strong>：</li>
<li>检查 <code>server/api/auth/github.get.ts</code> 中的 <code>onSuccess</code> 是否被调用。</li>
<li>确认生产环境使用的密码与加密时一致。</li>
</ul>

<h3 id="7-4-redirect-uri-mismatch">7.4 <code>redirect_uri_mismatch</code></h3>

<p><strong>现象</strong>：GitHub 返回错误“The redirect_uri MUST match the registered callback URL”。</p>

<p><strong>原因</strong>：生产环境使用的回调 URL 未在 GitHub OAuth App 中注册。</p>

<p><strong>解决</strong>：</p>

<ul>
<li>登录 GitHub，进入生产环境 OAuth App 设置，将 <code>https://你的域名/api/auth/github</code> 添加到回调 URL 列表。</li>
</ul>

<h3 id="7-5-登录后-pinia-store-报错">7.5 登录后 Pinia store 报错</h3>

<p><strong>现象</strong>：控制台出现 <code>t.$pinia.state.value.xxx is undefined</code>。</p>

<p><strong>原因</strong>：在 Pinia store 初始化完成前，某个组件试图访问 store 属性（常见于登录后的重定向瞬间）。</p>

<p><strong>解决</strong>：</p>

<ul>
<li>在访问 store 属性前加防御性判断：<code>store?.xxx ?? defaultValue</code>。</li>
<li>或在插件中提前初始化 store。</li>
</ul>

<hr>

<h2 id="八-开发与生产环境最佳实践总结">八、开发与生产环境最佳实践总结</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>开发环境</th>
<th>生产环境</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>GitHub OAuth App</strong></td>
<td>一个独立 App（<code>myapp-dev</code>）</td>
<td>另一个独立 App（<code>myapp-prod</code>）</td>
</tr>

<tr>
<td><strong>环境变量文件</strong></td>
<td><code>.env</code>（已 gitignore）</td>
<td>无，由系统环境变量 / CI Secrets 提供</td>
</tr>

<tr>
<td><strong>回调 URL</strong></td>
<td><code>http://localhost:3000/api/auth/github</code></td>
<td><code>https://你的域名/api/auth/github</code></td>
</tr>

<tr>
<td><strong>部署方式</strong></td>
<td><code>pnpm dev</code></td>
<td>CI/CD + PM2</td>
</tr>

<tr>
<td><strong>来源页重定向</strong></td>
<td>同左</td>
<td>同左（session 方案通用）</td>
</tr>

<tr>
<td><strong>常见陷阱</strong></td>
<td>无</td>
<td>换行符污染、静态文件缺失、密码不一致</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="九-版本兼容性说明">九、版本兼容性说明</h2>

<blockquote>
<p>⚠️ <strong>注意</strong>：<code>nuxt-auth-utils</code> 模块的 API 可能随版本更新而变化。本文基于 <code>v0.4.x</code> 编写，如果你使用的是更新版本，建议查阅<a href="https://github.com/Atinux/nuxt-auth-utils" target="_blank">官方文档</a>确认具体用法。核心原理和流程不变。</p>
</blockquote>

<hr>

<h2 id="十-结语">十、结语</h2>

<p>本文完整呈现了在 Nuxt 4 中集成 GitHub OAuth 的流程，涵盖基础原理、开发配置、生产部署，以及登录后重定向回来源页的进阶实现。这一功能不仅提升了用户体验，还自然解决了国际化场景下的水合不匹配问题。</p>

<p>掌握这些内容后，开发者能够独立实现第三方登录功能，并具备在生产环境中排查和解决复杂问题的能力。希望这份文档能成为你技术工具箱中的一份可靠参考。</p>
]]></content:encoded>
      <description><![CDATA[Nuxt 4 集成 GitHub 登录：从原理到实践（开发 + 生产环境完整版）]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[OAuth]]></category>
      <category><![CDATA[Security]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:backend]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 中安全实现状态持久化：根治水合失败指南]]></title>
      <link>https://moongate.top/docs/nuxt-state-persistence-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-state-persistence-guide</guid>
      <pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="问题根源-两个世界的状态冲突">问题根源：两个世界的状态冲突</h2>

<p>Nuxt 的 SSR 流程：</p>

<ol>
<li><strong>服务端</strong>：在 Node.js 环境生成 HTML，无法访问 <code>localStorage</code></li>
<li><strong>客户端</strong>：在浏览器激活页面，可以正常访问所有 Web API</li>
</ol>

<p><strong>水合失败</strong>发生在：服务端用一个状态（如 <code>viewMode = 1</code>）渲染 HTML，客户端用另一个状态（如从 <code>localStorage</code> 读出的 <code>viewMode = 2</code>）激活，两边不一致，Vue 报错。</p>

<pre><code class="language-ts">// 错误示例：直接在组件顶层读取 localStorage
const viewMode = ref(localStorage.getItem(&quot;viewMode&quot;) === &quot;2&quot; ? 2 : 1)
// 服务端报错，客户端值不一致 → 水合失败
</code></pre>

<h2 id="两类状态-两种处理方式">两类状态，两种处理方式</h2>

<h3 id="1-配置型状态-ssr-安全">1. 配置型状态（SSR 安全）</h3>

<ul>
<li>主题、语言、搜索选项等</li>
<li>初始值来自 SSR 安全 API（<code>useColorMode</code>、<code>useI18n</code>）或固定默认值</li>
<li><strong>可用 Pinia + 持久化插件</strong></li>
</ul>

<pre><code class="language-ts">export const useSettingsStore = defineStore(
  &quot;settings&quot;,
  () =&gt; {
    const colorMode = useColorMode() // SSR 安全
    const { locale } = useI18n() // SSR 安全

    const preferences = ref({
      theme: colorMode.preference,
      language: locale.value,
      searchOption: 1,
    })

    return { preferences }
  },
  { persist: true },
) // 插件只在客户端恢复状态
</code></pre>

<h3 id="2-ui-状态-高风险">2. UI 状态（高风险）</h3>

<ul>
<li>侧边栏折叠、面板显示、视图模式等</li>
<li><strong>直接影响初始 DOM 结构</strong></li>
<li><strong>用 <code>useLocalStorage</code>，不要进 Pinia</strong></li>
</ul>

<h2 id="解决方案-uselocalstorage">解决方案：useLocalStorage</h2>

<pre><code class="language-vue">&lt;script setup&gt;
import { useLocalStorage } from &quot;@vueuse/core&quot;
// 服务端返回默认值 1，客户端自动同步 localStorage
const viewMode = useLocalStorage(&quot;viewMode&quot;, 1)
&lt;/script&gt;
&lt;template&gt;
  &lt;USelect v-model=&quot;viewMode&quot; :items=&quot;viewModeOptions&quot; /&gt;
  &lt;UBlogPost
    v-for=&quot;post in posts&quot;
    :description=&quot;viewMode === 1 ? post.description : ''&quot;
  /&gt;
&lt;/template&gt;
</code></pre>

<h3 id="为什么能解决">为什么能解决？</h3>

<p><code>useLocalStorage</code> 的核心是 <strong>依赖注入 + 可选链</strong>：</p>

<pre><code class="language-ts">// 简化版原理
function useLocalStorage(key, initialValue) {
  // 关键：window?.localStorage 在服务端是 undefined
  const storage = import.meta.client ? localStorage : null
  return useStorage(key, initialValue, storage)
}
</code></pre>

<ul>
<li><strong>服务端</strong>：<code>storage = null</code>，直接返回 <code>initialValue</code>，无副作用</li>
<li><strong>客户端</strong>：<code>storage = localStorage</code>，自动读取并同步持久化值</li>
</ul>

<p>整个过程：</p>

<ul>
<li>服务端用默认值 1 渲染 HTML（带摘要）</li>
<li>客户端激活时，<code>useLocalStorage</code> 读到 localStorage 中的 2，更新视图</li>
<li>水合已完成，不会报错，用户看到最终状态</li>
</ul>

<h2 id="和-pinia-持久化的本质区别">和 Pinia 持久化的本质区别</h2>

<table>
<thead>
<tr>
<th>维度</th>
<th>Pinia 持久化</th>
<th>useLocalStorage</th>
</tr>
</thead>

<tbody>
<tr>
<td>设计模式</td>
<td>硬编码调用 <code>localStorage</code></td>
<td>依赖注入，环境感知</td>
</tr>

<tr>
<td>SSR 行为</td>
<td>正确配置时安全，但初始值只能用默认值</td>
<td>检测到无 storage → 安全降级</td>
</tr>

<tr>
<td>适用场景</td>
<td>配置型状态</td>
<td>UI 状态</td>
</tr>
</tbody>
</table>

<h3 id="核心检验标准">核心检验标准</h3>

<p>问自己“这个状态的初始值需要在服务端决定 DOM 结构吗？”</p>

<ul>
<li>是 → <code>useLocalStorage</code></li>
<li>否 → Pinia + 持久化插件（确保初始值 SSR 安全）</li>
</ul>

<h2 id="决策流程图">决策流程图</h2>

<pre><code class="language-mermaid">flowchart TD
    A[需要持久化的状态] --&gt; B{是否影响初始 DOM？&lt;br&gt;（如侧边栏、视图模式）}
    B -- 是 --&gt; C[useLocalStorage]
    B -- 否 --&gt; D{初始值能否在服务端安全获取？}
    D -- 是 --&gt; E[Pinia + 持久化插件]
    D -- 否 --&gt; C
</code></pre>

<h2 id="实战建议">实战建议</h2>

<ol>
<li><strong>不要在 Pinia store 顶层读 <code>localStorage</code></strong>，store 初始值必须 SSR 安全。</li>
<li><strong>UI 状态优先用 <code>useLocalStorage</code></strong>，简单可靠，无需进 store。</li>
<li><strong><code>ClientOnly</code> 不是万能药</strong>，会导致服务端空白，仅用于纯交互组件。</li>
<li><strong>务必测试生产构建</strong>：<code>pnpm build &amp;&amp; pnpm preview</code>，水合问题常在构建后暴露。</li>
</ol>

<h2 id="我的最终代码">我的最终代码</h2>

<pre><code class="language-vue">&lt;script setup&gt;
import { useLocalStorage } from &quot;@vueuse/core&quot;
const viewMode = useLocalStorage(&quot;viewMode&quot;, 1)
const viewModeOptions = [
  { id: 1, name: &quot;详细模式&quot; },
  { id: 2, name: &quot;简洁模式&quot; },
]
&lt;/script&gt;
&lt;template&gt;
  &lt;USelect v-model=&quot;viewMode&quot; :items=&quot;viewModeOptions&quot; /&gt;
  &lt;UBlogPost
    v-for=&quot;post in posts&quot;
    :description=&quot;viewMode === 1 ? post.description : ''&quot;
  /&gt;
&lt;/template&gt;
</code></pre>

<p>没有 Pinia，没有 <code>isHydrated</code>，没有 <code>ClientOnly</code>。代码简洁，SSR 安全，水合通过。</p>

<h2 id="总结">总结</h2>

<p>根治水合失败的核心原则：<strong>让服务端和客户端第一次渲染的结果保持一致</strong>。</p>

<ul>
<li>UI 状态 → <code>useLocalStorage</code></li>
<li>配置状态 → Pinia + 持久化插件（确保初始值 SSR 安全）</li>
</ul>

<p>这不是技巧问题，而是对 SSR 架构的理解问题。选择正确的工具，代码自然简洁。</p>

<blockquote>
<p>如果你的状态需要通过链接分享（如分页、搜索、视图模式），可以阅读我的另一篇文档<a href="./nuxt-url-state-guide">《Nuxt 中 URL 与状态双向绑定指南》</a>。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[介绍 Nuxt 4 中安全实现状态持久化的原理，以及如何用 useLocalStorage 解决 Nuxt 4 的 SSR 水合失败问题。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[State Management]]></category>
      <category><![CDATA[Hydration]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:relation><![CDATA[series:performance]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 博客 Sitemap 配置完整指南]]></title>
      <link>https://moongate.top/docs/nuxt-sitemap-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-sitemap-guide</guid>
      <pubDate>Wed, 04 Feb 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="为什么需要-sitemap">为什么需要 Sitemap？</h2>

<p>Sitemap（站点地图）是一个 XML 文件，它告诉搜索引擎你的网站上有哪些页面、这些页面的重要性以及更新频率。对于个人博客而言，Sitemap 能：</p>

<ul>
<li><strong>加速新内容收录</strong>：新发布的文档可以在几小时到几天内被搜索引擎发现</li>
<li><strong>提高内容覆盖率</strong>：确保所有文档都被索引，避免“孤岛页面”</li>
<li><strong>优化爬虫效率</strong>：合理分配搜索引擎抓取资源</li>
</ul>

<h2 id="基础配置方案">基础配置方案</h2>

<h3 id="方案一-静态-sitemap-最简单">方案一：静态 Sitemap（最简单）</h3>

<p>适用于文档数量少、更新频率低的博客。</p>

<p>在 <code>public/</code> 目录创建 <code>sitemap.xml</code>：</p>

<pre><code class="language-xml">&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
  &lt;url&gt;
    &lt;loc&gt;https://moongate.top/&lt;/loc&gt;
    &lt;priority&gt;1.0&lt;/priority&gt;
    &lt;changefreq&gt;daily&lt;/changefreq&gt;
  &lt;/url&gt;
  &lt;url&gt;
    &lt;loc&gt;https://moongate.top/about&lt;/loc&gt;
    &lt;priority&gt;0.5&lt;/priority&gt;
    &lt;changefreq&gt;monthly&lt;/changefreq&gt;
  &lt;/url&gt;
  &lt;!-- 手动添加其他文档 --&gt;
&lt;/urlset&gt;
</code></pre>

<p>在 <code>robots.txt</code> 中声明：</p>

<pre><code class="language-text">Sitemap: https://moongate.top/sitemap.xml
</code></pre>

<h3 id="方案二-动态-sitemap-推荐">方案二：动态 Sitemap（推荐）</h3>

<p>使用 Nuxt 3 服务器路由动态生成，适合经常更新的博客。</p>

<h2 id="完整实现步骤">完整实现步骤</h2>

<h3 id="1-创建动态-sitemap-端点">1. 创建动态 Sitemap 端点</h3>

<pre><code class="language-typescript">// server/routes/sitemap.xml.ts
export default defineEventHandler(async (event) =&gt; {
  const siteUrl = useRuntimeConfig().public.siteUrl

  try {
    // 1. 获取文档数据
    const docs = await queryCollection(event, &quot;docs&quot;).select(&quot;path&quot;).all()
    const about = await queryCollection(event, &quot;about&quot;).select(&quot;path&quot;).all()

    // 2. 构建URL数组
    const urls = [
      `${siteUrl}`,
      `${siteUrl}/docs`,
      `${siteUrl}/about`,
      `${siteUrl}/404`,
      ...docs.map((doc) =&gt; `${siteUrl}${doc.path}`),
      ...about.map((about) =&gt; `${siteUrl}${about.path}`),
    ]

    // 3. 生成XML（关键修改：添加换行和缩进）
    const xmlLines = [
      '&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;',
      '&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;',
    ]

    // 添加每个URL条目
    urls.forEach((url) =&gt; {
      xmlLines.push(&quot;  &lt;url&gt;&quot;)
      xmlLines.push(`    &lt;loc&gt;${url}&lt;/loc&gt;`)
      xmlLines.push(&quot;  &lt;/url&gt;&quot;)
    })

    // 闭合标签
    xmlLines.push(&quot;&lt;/urlset&gt;&quot;)

    // 组合成最终字符串（用换行符连接）
    const sitemap = xmlLines.join(&quot;\n&quot;)

    // 4. 设置响应头
    setResponseHeader(event, &quot;content-type&quot;, &quot;application/xml&quot;)
    setResponseHeader(event, &quot;Cache-Control&quot;, &quot;public, max-age=3600&quot;)

    return sitemap
  } catch (error) {
    console.error(&quot;生成Sitemap失败:&quot;, error)

    // 失败时返回最小化版本（同样格式化）
    const fallbackSitemap = `&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;&gt;
  &lt;url&gt;
    &lt;loc&gt;${siteUrl}&lt;/loc&gt;
  &lt;/url&gt;
&lt;/urlset&gt;`

    setResponseHeader(event, &quot;content-type&quot;, &quot;application/xml&quot;)
    return fallbackSitemap
  }
})
</code></pre>

<h3 id="2-配置环境变量">2. 配置环境变量</h3>

<p>在 <code>nuxt.config.ts</code> 中：</p>

<pre><code class="language-typescript">export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      siteUrl: process.env.SITE_URL,
    },
  },
})
</code></pre>

<p>在 <code>.env</code> 文件中：</p>

<pre><code class="language-bash">NUXT_PUBLIC_SITE_URL=https://moongate.top
</code></pre>

<h3 id="3-配置-robots-txt">3. 配置 robots.txt</h3>

<pre><code class="language-typescript">// server/routes/robots.txt.ts
export default defineEventHandler((event) =&gt; {
  const siteUrl = useRuntimeConfig().public.siteUrl

  return `User-agent: *
Allow: /
Allow: /*.css$
Allow: /*.js$

Sitemap: ${siteUrl}/sitemap.xml`
})
</code></pre>

<h2 id="生产环境部署要点">生产环境部署要点</h2>

<h3 id="github-actions-配置">GitHub Actions 配置</h3>

<p>确保构建时传递环境变量：</p>

<pre><code class="language-yaml">- name: Build
  run: |
    NUXT_PUBLIC_SITE_URL=https://moongate.top \
    pnpm run build
</code></pre>

<h3 id="pm2-配置文件">PM2 配置文件</h3>

<pre><code class="language-javascript">// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: &quot;moongate&quot;,
      script: &quot;./server/index.mjs&quot;,
      env: {
        NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;, // 关键！
        NODE_ENV: &quot;production&quot;,
      },
    },
  ],
}
</code></pre>

<h2 id="验证与测试">验证与测试</h2>

<h3 id="1-本地验证">1. 本地验证</h3>

<pre><code class="language-bash"># 检查 XML 格式
curl http://localhost:3000/sitemap.xml | xmllint --format -

# 检查可访问性
curl -I http://localhost:3000/sitemap.xml
</code></pre>

<h3 id="2-在线工具验证">2. 在线工具验证</h3>

<ul>
<li><a href="https://search.google.com/search-console/sitemaps" target="_blank">Google Sitemap 测试工具</a></li>
<li><a href="https://www.xml-sitemaps.com/validate-xml-sitemap.html" target="_blank">XML 验证器</a></li>
</ul>

<h3 id="3-提交到搜索引擎">3. 提交到搜索引擎</h3>

<ol>
<li>Google Search Console → Sitemaps → 提交 URL</li>
<li>Bing Webmaster Tools → Sitemaps</li>
<li>百度搜索资源平台 → 链接提交 → Sitemap</li>
</ol>

<h2 id="常见问题解决">常见问题解决</h2>

<h3 id="q1-sitemap-返回-404">Q1: Sitemap 返回 404</h3>

<ul>
<li>检查文件路径：<code>server/routes/sitemap.xml.ts</code></li>
<li>确认 Nuxt 服务器路由配置正确</li>
</ul>

<h3 id="q2-url-不完整-缺少-https">Q2: URL 不完整（缺少 https://）</h3>

<pre><code class="language-typescript">// 正确：完整的 URL
;`${siteUrl}/docs/${slug}`
// 错误：相对路径
`/docs/${slug}`
</code></pre>

<h3 id="q3-生产环境-siteurl-为空">Q3: 生产环境 siteUrl 为空</h3>

<p>添加需要的环境变量</p>

<pre><code class="language-yaml">- name: Build
  run: |
    NUXT_PUBLIC_SITE_URL=https://moongate.top \
    pnpm run build
</code></pre>

<blockquote>
<p>这确保了 Nuxt 在构建时将 <code>public.siteUrl</code> 正确内嵌到客户端和服务端包中。</p>
</blockquote>

<p>添加ecosystem.config.js配置脚本</p>

<pre><code class="language-yaml">script: |
echo &quot;🚀 启动 Node.js SSR 服务...&quot;
cd /var/www/my-site

# 创建或更新 PM2 配置文件
cat &gt; ecosystem.config.js &lt;&lt; 'EOF'
module.exports = {
  apps: [{
    name: &quot;moongate&quot;,
    script: &quot;./server/index.mjs&quot;,
    instances: 1,
    exec_mode: &quot;fork&quot;,
    env: {
      NODE_ENV: &quot;production&quot;,
      NUXT_PUBLIC_SITE_URL: &quot;https://moongate.top&quot;,
      PORT: 3000,
      HOST: &quot;0.0.0.0&quot;
    }
  }]
}
EOF

echo &quot;📁 PM2 配置文件已生成&quot;

# 使用配置文件管理应用
if pm2 describe moongate &gt; /dev/null 2&gt;&amp;1; then
  echo &quot;🔄 重启现有应用（使用最新配置）...&quot;
  pm2 reload ecosystem.config.js --update-env
else
  echo &quot;🚀 启动新应用...&quot;
  pm2 start ecosystem.config.js
fi

# 保存 PM2 配置以便开机自启
pm2 save

echo &quot;✅ 服务启动完成&quot;
pm2 status moongate
</code></pre>

<h2 id="最佳实践总结">最佳实践总结</h2>

<ol>
<li><strong>使用动态生成</strong>：适合经常更新的博客</li>
<li><strong>包含所有重要页面</strong>：首页、文档页、分类页、关于页</li>
<li><strong>使用绝对 URL</strong>：始终包含 <code>https://</code></li>
<li><strong>设置合理缓存</strong>：<code>Cache-Control: public, max-age=3600</code></li>
<li><strong>监控索引状态</strong>：定期检查 Google Search Console</li>
<li><strong>保持更新</strong>：内容更新后及时更新 <code>lastmod</code> 字段</li>
<li><strong>验证格式</strong>：部署前使用 XML 验证工具检查</li>
</ol>

<h2 id="效果监测">效果监测</h2>

<p>部署 Sitemap 后，关注以下指标：</p>

<ol>
<li><strong>索引覆盖率</strong>（Google Search Console）</li>
<li><strong>爬行统计</strong>：成功 vs 错误的 URL 数量</li>
<li><strong>搜索表现</strong>：关键词排名和点击率变化</li>
<li><strong>收录速度</strong>：新文档从发布到被收录的时间</li>
</ol>

<p>通常配置正确的 Sitemap 能在 1-2 周内显著改善新内容的收录速度。</p>

<hr>

<p><em>本文基于 Nuxt 4 和实际部署经验编写，适用于使用 SSR 或静态生成的博客。根据你的具体需求调整数据源和 URL 结构。</em></p>
]]></content:encoded>
      <description><![CDATA[介绍了 Nuxt 4 博客 Sitemap 配置的基础知识、静态 Sitemap 和动态 Sitemap 的实现方法，并提供了生产环境部署要点。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[SEO]]></category>
      <category><![CDATA[Engineering]]></category>
      <dc:relation><![CDATA[series:ecosystem]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Caddy 全自动部署动态网站（动态篇）]]></title>
      <link>https://moongate.top/docs/dynamic-site-auto-deploy</link>
      <guid isPermaLink="true">https://moongate.top/docs/dynamic-site-auto-deploy</guid>
      <pubDate>Fri, 23 Jan 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<blockquote>
<p><strong>适用技术栈</strong>：本教程以 <strong>Nuxt + Drizzle ORM + PostgreSQL</strong> 为例，完整展示一个现代化动态网站的自动化部署流程。整体架构与配置方法同样适用于其他 Node.js 框架（Express、NestJS）或其他语言的技术栈，只需替换对应的构建、迁移、进程管理命令即可。</p>
</blockquote>

<p>本教程将指导你搭建一套 <strong>“代码推送即发布”</strong> 的自动化部署系统，实现从本地 <code>git push</code> 到服务器服务热重启的全流程无人值守。</p>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>Node.js、pnpm、Caddy、GitHub Actions、阿里云 ACR 的版本信息与<a href="./static-site-auto-deploy">静态篇</a>一致。本文额外涉及：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>PostgreSQL</td>
<td>17 (alpine)</td>
<td>轻量级关系型数据库，alpine 版本镜像小巧</td>
</tr>

<tr>
<td>Drizzle ORM</td>
<td>0.30+</td>
<td>TypeScript 原生 ORM，支持迁移和类型安全查询</td>
</tr>

<tr>
<td>PM2</td>
<td>5+</td>
<td>生产级 Node.js 进程管理工具</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="系统架构与核心理念">🎯 系统架构与核心理念</h2>

<p>动态网站部署需要处理：</p>

<ol>
<li>运行时环境安装</li>
<li>项目依赖安装</li>
<li>数据库迁移（使用 Drizzle ORM）</li>
<li>应用进程管理（PM2）</li>
<li>反向代理与 HTTPS（Caddy）</li>
</ol>

<p>整个流程基于 <strong>声明式自动化</strong>，通过 GitHub Actions 串联所有步骤。</p>

<pre><code class="language-bash">开发者本地 (Local)
↓ [git push]
GitHub 仓库 (Repository)
↓ [触发]
GitHub Actions (CI/CD 管道)
├─ 检出代码
├─ 安装依赖
├─ 运行测试（可选）
├─ 构建项目（Nuxt 生成 .output）
├─ 同步代码至服务器
└─ 远程执行命令（依赖安装、迁移、重启）
阿里云服务器 (ECS)
├─ PM2 守护应用进程
├─ Caddy 反向代理 + 自动 HTTPS
└─ PostgreSQL 数据库
用户访问 → HTTPS → Caddy → 应用（.output/server/index.mjs）→ 数据库
</code></pre>

<hr>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>一个 GitHub 仓库</strong>，包含你的动态网站源码，并已集成 Drizzle ORM。

<ul>
<li>确保项目包含 <code>drizzle.config.ts</code>、数据库 schema 文件（如 <code>server/db/schema.ts</code>）。</li>
<li><strong>注意 Drizzle 迁移目录可能不同</strong>：默认生成在 <code>drizzle</code> 目录，但你可能配置为 <code>.drizzle</code> 或其他名称。请根据你的 <code>drizzle.config.ts</code> 中的 <code>out</code> 字段确认实际目录，并在后续步骤中保持一致。</li>
<li><strong>迁移文件必须提前生成并提交到 Git</strong>：在本地运行 <code>pnpm drizzle-kit generate</code> 生成初始迁移文件，然后 <code>git add</code> 并提交。</li>
</ul></li>
<li><strong>一台云服务器</strong>（阿里云 ECS 等），建议 Ubuntu 24.04（确保支持最新软件包）。

<ul>
<li>安全组必须开放：<strong>SSH(22)</strong>、<strong>HTTP(80)</strong>、<strong>HTTPS(443)</strong> 端口。</li>
</ul></li>
<li><strong>一个域名</strong>（强烈推荐，用于自动 HTTPS），并已解析到服务器 IP。</li>
<li><strong>PostgreSQL 数据库</strong>（可安装在服务器上，或使用云数据库如阿里云 RDS）。无论数据库在哪，你需要一个可访问的连接字符串（<code>DATABASE_URL</code>）。</li>
</ol>

<hr>

<h2 id="第一部分-服务器初始化">🚀 第一部分：服务器初始化</h2>

<h3 id="1-1-登录并安装基础软件">1.1 登录并安装基础软件</h3>

<p>通过 SSH 登录你的云服务器。<strong>注意</strong>：下文假设用户名为 <code>ubuntu</code>，请根据你的实际用户名替换。</p>

<pre><code class="language-bash"># 更新系统包
sudo apt update &amp;&amp; sudo apt upgrade -y

# 安装 Caddy（使用官方仓库，确保最新版本）
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy

# 验证 Caddy 版本（需 &gt;= 2.6.0）
caddy version

# 安装 Node.js 24（当前最新版本）
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node --version  # 应输出 v24.x.x

# 启用 corepack 以使用 pnpm
sudo corepack enable
# 安装 pnpm 10 最新版
corepack prepare pnpm@latest --activate
pnpm --version  # 应输出 10.x.x

# 安装 PM2 全局进程管理（使用 sudo 确保权限）
sudo npm install -g pm2
# 或者用 pnpm（但注意全局路径）：
# sudo pnpm add -g pm2

# 安装 PostgreSQL 客户端（可选，用于调试）
sudo apt install -y postgresql-client

# 创建应用目录，并设置正确所有者（使用你的实际用户名）
sudo mkdir -p /var/www/my-dynamic-app
sudo chown -R ubuntu:ubuntu /var/www/my-dynamic-app
</code></pre>

<h3 id="1-2-配置-caddy-作为反向代理">1.2 配置 Caddy 作为反向代理</h3>

<p>编辑 Caddy 配置文件：</p>

<pre><code class="language-bash">sudo nano /etc/caddy/Caddyfile
</code></pre>

<p>写入以下内容（<strong>务必替换 <code>example.com</code> 为你的域名</strong>，Nuxt 默认运行在 3000 端口）：</p>

<pre><code class="language-bash">example.com, www.example.com {
    # 反向代理到本地 Nuxt 应用进程
    reverse_proxy 127.0.0.1:3000
    # 启用压缩
    encode gzip zstd
}
</code></pre>

<blockquote>
<p><strong>⚠️ 重要</strong>：Nuxt 应用在运行时必须监听 <code>127.0.0.1</code>（即 localhost），以确保只能通过 Caddy 访问。在 Nuxt 中默认监听所有接口，你需要在启动命令中指定 <code>HOST=127.0.0.1</code> 或通过环境变量控制。</p>
</blockquote>

<p>验证并重启 Caddy：</p>

<pre><code class="language-bash">sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
sudo systemctl status caddy
</code></pre>

<blockquote>
<p>如果使用域名，Caddy 会在首次 HTTPS 请求时自动申请 Let&rsquo;s Encrypt 证书。</p>
</blockquote>

<h3 id="1-3-准备数据库连接">1.3 准备数据库连接</h3>

<p>无论数据库在服务器本地还是云端，你都需要一个有效的连接字符串，格式如：</p>

<pre><code class="language-bash">postgresql://用户名:密码@主机:端口/数据库名
</code></pre>

<ul>
<li><strong>如果数据库在服务器本地</strong>（通过 apt 安装）：</li>
</ul>

<pre><code class="language-bash">  sudo apt install -y postgresql
  sudo systemctl start postgresql
  sudo systemctl enable postgresql
  sudo -u postgres psql
  # 在 psql 中执行：
  CREATE DATABASE mydb;
  CREATE USER myuser WITH ENCRYPTED PASSWORD 'mypassword';
  GRANT ALL PRIVILEGES ON DATABASE mydb TO myuser;
  \q
</code></pre>

<p>此时连接字符串为 <code>postgresql://myuser:mypassword@localhost:5432/mydb</code>。</p>

<ul>
<li><strong>如果使用云数据库</strong>（如阿里云 RDS），直接在控制台获取连接串。</li>
</ul>

<p>请确保你的数据库可以从服务器访问（如果是云数据库，需在安全组中放行服务器 IP）。</p>

<hr>

<h2 id="第二部分-配置-ssh-密钥对与-github-secrets">🔐 第二部分：配置 SSH 密钥对与 GitHub Secrets</h2>

<p>SSH 密钥生成、公钥部署、私钥配置的完整流程请参见<a href="./static-site-auto-deploy">静态篇 第二部分</a>。本文额外需要：</p>

<table>
<thead>
<tr>
<th>Secret 名称</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>DATABASE_URL</code></td>
<td>数据库连接字符串</td>
</tr>

<tr>
<td>其他环境变量</td>
<td>如 <code>NUXT_SESSION_PASSWORD</code>、<code>NUXT_OAUTH_GITHUB_CLIENT_ID</code> 等</td>
</tr>
</tbody>
</table>

<hr>

<h2 id="第三部分-创建-github-actions-工作流">⚙️ 第三部分：创建 GitHub Actions 工作流</h2>

<p>在项目根目录创建 <code>.github/workflows/deploy.yml</code>。以下是一个完整的、适配 <strong>Node.js 24 + pnpm 10 + Nuxt + Drizzle ORM</strong> 的示例。</p>

<pre><code class="language-yaml">name: Deploy Dynamic App to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      # 设置 Node.js 24 环境
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &quot;24&quot; # 使用 Node.js 24
          cache: &quot;pnpm&quot; # 启用 pnpm 缓存

      # 启用 corepack 并安装 pnpm 10
      - name: Install pnpm
        run: |
          corepack enable
          corepack prepare pnpm@latest --activate
          pnpm --version  # 应输出 10.x

      # 安装依赖（包含所有依赖）
      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # 可选：运行测试
      - name: Run tests
        run: pnpm test
        continue-on-error: true

      # 构建 Nuxt 应用（生成 .output 目录）
      - name: Build Nuxt app
        run: pnpm run build
        env:
          # 构建时可能需要的环境变量
          NUXT_PUBLIC_SITE_URL: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      # 同步代码到服务器（排除不需要的文件）
      # 注意：burnett01/rsync-deployments 是一个社区维护的 action，你可以审查其源码：
      # https://github.com/burnett01/rsync-deployments
      - name: Deploy to Server via Rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avz --delete --exclude='.env' --exclude='node_modules' --exclude='.git'
          path: ./ # 同步整个项目（.output 是构建产物，需要同步）
          remote_path: /var/www/my-dynamic-app/
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: ${{ secrets.SERVER_USER }}
          remote_key: ${{ secrets.SSH_PRIVATE_KEY }}

      # 在服务器上执行远程命令（核心步骤）
      - name: Remote execution
        uses: appleboy/ssh-action@v1.0.0
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          NUXT_SESSION_PASSWORD: ${{ secrets.NUXT_SESSION_PASSWORD }}
          NUXT_OAUTH_GITHUB_CLIENT_ID: ${{ secrets.NUXT_OAUTH_GITHUB_CLIENT_ID }}
          NUXT_OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.NUXT_OAUTH_GITHUB_CLIENT_SECRET }}
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          envs: DATABASE_URL, NUXT_SESSION_PASSWORD, NUXT_OAUTH_GITHUB_CLIENT_ID, NUXT_OAUTH_GITHUB_CLIENT_SECRET
          script: |
            set -e  # 遇到任何错误立即退出

            cd /var/www/my-dynamic-app

            # 创建环境变量文件（一次性写入，注意不要用单引号，否则变量不会展开）
            cat &gt; .env &lt;&lt; EOF
            DATABASE_URL=$DATABASE_URL
            NUXT_SESSION_PASSWORD=$NUXT_SESSION_PASSWORD
            NUXT_OAUTH_GITHUB_CLIENT_ID=$NUXT_OAUTH_GITHUB_CLIENT_ID
            NUXT_OAUTH_GITHUB_CLIENT_SECRET=$NUXT_OAUTH_GITHUB_CLIENT_SECRET
            NODE_ENV=production
            EOF

            # 设置 .env 文件权限，防止其他用户读取
            chmod 600 .env

            # 启用 corepack 并安装 pnpm（服务器上可能没有）
            export PNPM_HOME=~/.local/share/pnpm
            export PATH=$PNPM_HOME:$PATH
            if ! command -v pnpm &amp;&gt; /dev/null; then
              corepack enable
              corepack prepare pnpm@latest --activate
            fi

            # 安装生产依赖
            pnpm install --prod --frozen-lockfile

            # 执行数据库迁移
            # 注意：请根据你的 drizzle.config.ts 中的 out 目录调整
            # 默认是 drizzle，也可能是 .drizzle 或其他名称
            # 迁移需要 drizzle-kit，临时安装
            pnpm add -D drizzle-kit
            npx drizzle-kit migrate
            # 迁移完成后可卸载（可选）
            # pnpm remove drizzle-kit

            # 重启应用（使用 PM2）
            # 注意：如果使用 Nuxt，启动命令应为：
            # pm2 start .output/server/index.mjs --name &quot;my-app&quot; -- --host 127.0.0.1
            if pm2 describe my-app &gt; /dev/null 2&gt;&amp;1; then
              pm2 reload my-app
            else
              pm2 start npm --name &quot;my-app&quot; -- start
              pm2 save
              pm2 startup
            fi

            pm2 save
</code></pre>

<h3 id="关键说明">关键说明</h3>

<ul>
<li><strong>Node.js 24 + pnpm 10</strong>：所有步骤都针对最新版本优化，包括 corepack 的启用和 pnpm 的安装方式。</li>
<li><strong>Nuxt 构建产物</strong>：<code>.output</code> 目录必须被同步，rsync 命令中<strong>没有排除</strong> <code>.output</code>。</li>
<li><strong>Drizzle 迁移目录</strong>：请根据你的 <code>drizzle.config.ts</code> 中的 <code>out</code> 字段确认迁移文件目录（可能是 <code>drizzle</code>、<code>.drizzle</code> 或自定义名称）。该目录必须被同步到服务器，因此不应在 rsync 中排除。</li>
<li><strong>环境变量</strong>：所有运行时需要的变量通过 GitHub Secrets 传递，并在远程脚本中写入 <code>.env</code>（注意此处使用 <code>&lt;&lt; EOF</code> 而非 <code>'EOF'</code>，确保变量正确展开）。</li>
<li><strong>PM2 启动命令</strong>：Nuxt 的启动入口是 <code>.output/server/index.mjs</code>，并通过 <code>--host 127.0.0.1</code> 确保只监听本地。</li>
<li><strong>服务器上的 pnpm</strong>：远程脚本中检测并安装 pnpm，确保服务器环境一致。</li>
<li><strong>迁移依赖</strong>：临时安装 <code>drizzle-kit</code> 执行迁移，之后可卸载，保持生产环境干净。</li>
</ul>

<hr>

<h2 id="第四部分-触发首次部署与验证">🧪 第四部分：触发首次部署与验证</h2>

<h3 id="4-1-提交并推送代码">4.1 提交并推送代码</h3>

<pre><code class="language-bash">git add .github/workflows/deploy.yml
git commit -m &quot;ci: 添加动态网站自动化部署&quot;
git push origin main
</code></pre>

<h3 id="4-2-监控部署过程">4.2 监控部署过程</h3>

<p>在 GitHub 仓库的 <strong>Actions</strong> 标签页查看运行状态。成功时所有步骤应为绿色。</p>

<h3 id="4-3-验证服务">4.3 验证服务</h3>

<ul>
<li>访问 <code>https://你的域名</code>，确认网站功能正常。</li>
<li>检查服务器进程：<code>pm2 status</code> 应显示 <code>nuxt-app</code> 为 <code>online</code>。</li>
<li>查看应用日志：<code>pm2 logs nuxt-app</code>。</li>
<li>查看 Caddy 日志：<code>sudo journalctl -u caddy -f</code>。</li>
</ul>

<hr>

<h2 id="第五部分-高级配置与问题排查">🔧 第五部分：高级配置与问题排查</h2>

<h3 id="5-1-drizzle-迁移的最佳实践">5.1 Drizzle 迁移的最佳实践</h3>

<ul>
<li><strong>迁移文件必须提交到 Git</strong>，确保 CI 和服务器能获取到相同的迁移历史。注意你的迁移目录可能是 <code>.drizzle</code>（以点开头），在 Git 中需要显式添加（<code>git add .drizzle</code>）。</li>
<li><strong>首次部署前</strong>，在本地运行 <code>pnpm drizzle-kit generate</code> 生成初始迁移文件并提交。后续每次修改 schema 后，同样在本地生成新迁移文件并提交。</li>
<li><strong>迁移目录的 rsync 排除</strong>：确保你的迁移目录<strong>不被 rsync 排除</strong>。检查 <code>--exclude</code> 参数中是否误排了类似 <code>.drizzle</code> 的目录。</li>
</ul>

<h3 id="5-2-nuxt-应用监听地址">5.2 Nuxt 应用监听地址</h3>

<p>确保 Nuxt 应用监听 <code>127.0.0.1</code> 而非 <code>0.0.0.0</code>。可以通过以下方式之一实现：</p>

<ul>
<li>在启动命令中指定：<code>pm2 start .output/server/index.mjs --name &quot;nuxt-app&quot; -- --host 127.0.0.1</code></li>
<li>或在 <code>nuxt.config.ts</code> 中配置：

<pre><code class="language-ts">
export default defineNuxtConfig({
nitro: {
  devServer: {
    host: &quot;127.0.0.1&quot;,
  },
},
})
</code></pre>
</li>
</ul>

<h3 id="5-3-环境变量安全">5.3 环境变量安全</h3>

<ul>
<li>不要在代码中硬编码敏感信息，全部通过 GitHub Secrets 注入。</li>
<li>服务器上的 <code>.env</code> 文件权限设为 <code>600</code>（已在脚本中执行 <code>chmod 600 .env</code>）。</li>
</ul>

<h3 id="5-4-pm2-开机自启的完整操作">5.4 PM2 开机自启的完整操作</h3>

<ol>
<li>首次部署成功后，登录服务器。</li>
<li>执行 <code>pm2 startup</code>，复制输出的带有 <code>sudo</code> 的命令（如 <code>sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u ubuntu --hp /home/ubuntu</code>）。</li>
<li>粘贴并执行该命令，输入服务器密码（如果需要）。</li>
<li>之后执行 <code>pm2 save</code> 确保当前进程列表被保存。</li>
</ol>

<h3 id="5-5-关键问题排查清单">5.5 关键问题排查清单</h3>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Caddy 返回 502 Bad Gateway</strong></td>
<td>后端应用未运行、Caddy 配置端口错误、应用绑定地址不是 127.0.0.1</td>
<td>检查 <code>pm2 status</code>；确认 Caddyfile 中的端口；确保应用监听 <code>127.0.0.1</code></td>
</tr>

<tr>
<td><strong>应用启动失败</strong></td>
<td>依赖未安装、环境变量缺失、端口被占用、入口文件路径错误</td>
<td>登录服务器手动运行 <code>pnpm install</code>；检查 <code>.env</code> 文件；<code>netstat -tlnp \| grep 3000</code> 查看端口占用；确认 <code>.output/server/index.mjs</code> 是否存在</td>
</tr>

<tr>
<td><strong>数据库迁移失败</strong></td>
<td>数据库连接串错误、迁移文件缺失、数据库服务未启动、drizzle-kit 未安装</td>
<td>检查 <code>DATABASE_URL</code> 是否正确；确认迁移目录（如 <code>.drizzle</code>）存在；检查数据库服务状态；确保 <code>drizzle-kit</code> 已安装</td>
</tr>

<tr>
<td><strong>迁移目录找不到</strong></td>
<td>rsync 排除了点开头的目录</td>
<td>检查 rsync 命令的 <code>--exclude</code> 参数，确保没有排除 <code>.drizzle</code> 或你的自定义迁移目录</td>
</tr>

<tr>
<td><strong>pnpm 命令未找到</strong></td>
<td>服务器未安装 pnpm，或 PATH 未设置</td>
<td>检查远程脚本中是否正确安装了 pnpm，并设置了 <code>PATH</code></td>
</tr>

<tr>
<td><strong>PM2 进程在服务器重启后未恢复</strong></td>
<td>未执行 <code>pm2 startup</code> 后的 sudo 命令</td>
<td>登录服务器，重新执行 <code>pm2 startup</code> 并根据提示运行 sudo 命令</td>
</tr>
</tbody>
</table>

<h3 id="5-6-查看日志">5.6 查看日志</h3>

<pre><code class="language-bash"># 查看应用日志
pm2 logs nuxt-app

# 查看 Caddy 日志
sudo journalctl -u caddy -f

# 查看系统认证日志（SSH 问题）
sudo tail -f /var/log/auth.log
</code></pre>

<hr>

<h2 id="总结-你现在拥有了什么">📈 总结：你现在拥有了什么</h2>

<ol>
<li><strong>全自动部署</strong>：一次 <code>git push</code>，从代码到服务更新全部自动化。</li>
<li><strong>最新工具链</strong>：Node.js 24 + pnpm 10，享受最新性能和特性。</li>
<li><strong>Nuxt 专属优化</strong>：正确处理 <code>.output</code> 构建产物和启动方式。</li>
<li><strong>数据库迁移集成</strong>：Drizzle ORM 的迁移在部署时自动执行，支持自定义迁移目录（如 <code>.drizzle</code>）。</li>
<li><strong>进程守护</strong>：PM2 保证应用持续运行，崩溃自动重启。</li>
<li><strong>自动 HTTPS</strong>：Caddy 自动申请和续期 SSL 证书。</li>
<li><strong>安全可控</strong>：所有敏感信息通过 GitHub Secrets 管理，服务器上的 <code>.env</code> 文件权限严格。</li>
<li><strong>跨技术栈适配</strong>：本教程的结构可轻松迁移到其他语言和框架。</li>
</ol>

<h3 id="下一步">下一步</h3>

<p>如果你的项目需要更复杂的多容器编排（如应用、数据库、Redis 等），可以考虑迁移到 Docker 部署（参见本系列《进阶 Docker 篇》）。</p>
]]></content:encoded>
      <description><![CDATA[深入后端服务的进程管理、环境变量注入、数据库迁移，结合 Caddy 反向代理，打造完整的动态应用部署流水线。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[GitHub Actions + Caddy 静态网站自动化部署（静态篇）]]></title>
      <link>https://moongate.top/docs/static-site-auto-deploy</link>
      <guid isPermaLink="true">https://moongate.top/docs/static-site-auto-deploy</guid>
      <pubDate>Thu, 22 Jan 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<p>本教程将完整复现一个现代化静态网站从本地开发到自动化部署的全流程。你将搭建一套 <strong>“Git推送即发布”</strong> 的自动化系统，无需手动操作服务器。教程基于 <strong>Nuxt.js</strong> 静态生成，但核心流程适用于任何静态网站（如VitePress、Next.js SSG、Hugo等）。</p>

<blockquote>
<p><strong>最终效果</strong>：本地 <code>git push</code> → 自动构建、测试 → 安全同步至云服务器 → 网站即刻更新（HTTPS自动启用）。</p>
</blockquote>

<hr>

<h2 id="版本声明">📌 版本声明</h2>

<p>本文档所有工具均采用 <strong>2026 年最新稳定版</strong>，具体版本如下：</p>

<table>
<thead>
<tr>
<th>工具</th>
<th>版本</th>
<th>说明</th>
</tr>
</thead>

<tbody>
<tr>
<td>Node.js</td>
<td>24.x</td>
<td>最新的主要版本，支持所有现代 JavaScript 特性</td>
</tr>

<tr>
<td>pnpm</td>
<td>10.x</td>
<td>高性能包管理器，与 Node.js 24 完美兼容</td>
</tr>

<tr>
<td>Caddy</td>
<td>2.8+</td>
<td>自动 HTTPS 的反向代理服务器</td>
</tr>

<tr>
<td>GitHub Actions</td>
<td>最新</td>
<td>CI/CD 平台，所有 Action 均为当前最新版本（如 <code>checkout@v4</code>、<code>ssh-action@v1.0.0</code> 等）</td>
</tr>

<tr>
<td>阿里云 ACR</td>
<td>–</td>
<td>容器镜像服务，需使用固定密码进行认证</td>
</tr>
</tbody>
</table>

<blockquote>
<p><strong>注意</strong>：请根据你的项目实际需求调整具体版本号。若使用其他技术栈（如 Python、Java 等），请替换对应的运行时版本。</p>
</blockquote>

<hr>

<h2 id="系统架构与核心理念">🎯 系统架构与核心理念</h2>

<p>这套方案的核心是 <strong>“声明式自动化”</strong>：你只需在代码仓库中声明“做什么”（配置文件），GitHub Actions 和 Caddy 就会自动执行“怎么做”。</p>

<pre><code class="language-text">开发者本地 (Local)
↓ [git push]
GitHub 仓库 (Repository)
↓ [触发]
GitHub Actions (CI/CD 管道)
↓ [构建、测试、同步]
阿里云服务器 (Alibaba Cloud ECS)
↓ [Caddy 提供 HTTPS 服务]
用户访问 (HTTPS Website)
</code></pre>

<h2 id="前置准备">📦 前置准备</h2>

<ol>
<li><strong>一个 GitHub 仓库</strong></li>
<li><strong>一台阿里云 ECS 实例</strong>（或任何具有公网 IP 的 Linux 服务器）

<ul>
<li>推荐系统：Ubuntu 22.04 / Alibaba Cloud Linux 3</li>
<li><strong>安全组必须开放</strong>：<strong>SSH(22)</strong>、<strong>HTTP(80)</strong>、<strong>HTTPS(443)</strong> 端口（请登录阿里云控制台检查确认）</li>
</ul></li>
<li><strong>一个域名</strong>（可选，但推荐。教程以 <code>example.com</code> 为例）</li>
</ol>

<hr>

<h2 id="第一部分-服务器初始化">🚀 第一部分：服务器初始化</h2>

<h3 id="1-1-登录并安装基础软件">1.1 登录并安装基础软件</h3>

<p>通过 SSH 登录你的云服务器（假设登录用户名为 <code>your-user</code>，后续步骤中请将 <code>$USER</code> 替换为实际用户名）。</p>

<pre><code class="language-bash"># 更新系统包
sudo apt update &amp;&amp; sudo apt upgrade -y

# 安装 Caddy（现代化的 Web 服务器，自动 HTTPS）
sudo apt install caddy

# 安装 Node.js 环境
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs

# 创建网站根目录，并将所有权交给当前登录用户（确保后续 rsync 有写入权限）
sudo mkdir -p /var/www/my-site
sudo chown -R $USER:$USER /var/www/my-site
</code></pre>

<h3 id="1-2-配置-caddy">1.2 配置 Caddy</h3>

<p>编辑 Caddy 的主配置文件，告诉它如何服务你的网站。</p>

<pre><code class="language-bash">sudo nano /etc/caddy/Caddyfile
</code></pre>

<p>粘贴以下配置，<strong>请务必将 <code>example.com</code> 替换为你的真实域名</strong>。如果没有域名，可以用 <code>http://你的服务器IP</code> 格式，但将无法享受自动 HTTPS。</p>

<pre><code class="language-bash">example.com, www.example.com {
    # 网站根目录（必须与后续自动化部署的目录一致）
    root * /var/www/my-site
    # 启用静态文件服务器
    file_server
    # 对单页应用(SPA)至关重要：使前端路由正常工作
    try_files {path} /index.html
    # 启用压缩
    encode gzip zstd
}
</code></pre>

<p>保存退出后（在 nano 中：<code>Ctrl+X</code>，然后 <code>Y</code>，再 <code>Enter</code>），重启 Caddy 使配置生效。</p>

<pre><code class="language-bash"># 检查配置语法
sudo caddy validate --config /etc/caddy/Caddyfile
# 重启服务
sudo systemctl restart caddy
# 检查服务状态
sudo systemctl status caddy
</code></pre>

<blockquote>
<p><strong>💡 提示</strong>：看到 <code>active (running)</code> 状态即表示 Caddy 已就绪。如果使用域名，Caddy 会在首次访问时<strong>自动申请并配置 Let&rsquo;s Encrypt SSL 证书</strong>。</p>
</blockquote>

<hr>

<h2 id="第二部分-配置-ssh-密钥对与-github-secrets">🔐 第二部分：配置 SSH 密钥对与 GitHub Secrets</h2>

<p>自动化部署的核心是让 GitHub Actions 能安全地连接到你的服务器。我们使用 SSH 密钥对进行认证。</p>

<h3 id="2-1-在本地生成-ssh-密钥对">2.1 在本地生成 SSH 密钥对</h3>

<p>在你的<strong>本地电脑</strong>（而非服务器）上执行：</p>

<pre><code class="language-bash"># 生成一对新的密钥，专用于自动化部署
ssh-keygen -t ed25519 -f ~/.ssh/id_github_actions -N &quot;&quot;
</code></pre>

<p>这将生成两个文件：</p>

<ul>
<li><strong>私钥</strong> (<code>~/.ssh/id_github_actions</code>)：<strong>绝密</strong>，相当于你的“钥匙”。</li>
<li><strong>公钥</strong> (<code>~/.ssh/id_github_actions.pub</code>)：可以公开，相当于“锁芯”。</li>
</ul>

<h3 id="2-2-将公钥部署到服务器">2.2 将公钥部署到服务器</h3>

<ol>
<li>复制公钥内容：</li>
</ol>

<pre><code class="language-bash">   cat ~/.ssh/id_github_actions.pub
</code></pre>

<ol>
<li><strong>登录你的云服务器</strong>，将公钥添加到授权列表：</li>
</ol>

<pre><code class="language-bash">   # 将上一步复制的公钥内容，粘贴到引号内，然后执行整条命令
   echo '你的公钥内容' &gt;&gt; ~/.ssh/authorized_keys
   # 设置正确的权限（非常重要！）
   chmod 600 ~/.ssh/authorized_keys
   chmod 700 ~/.ssh
</code></pre>

<h3 id="2-3-将私钥配置为-github-secrets">2.3 将私钥配置为 GitHub Secrets</h3>

<ol>
<li>查看私钥内容：</li>
</ol>

<pre><code class="language-bash">   cat ~/.ssh/id_github_actions
</code></pre>

<ol>
<li>进入你的 GitHub 仓库，点击 <strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong>。</li>
<li>点击 <strong>New repository secret</strong>，添加以下三个密钥：

<ul>
<li><strong><code>SERVER_HOST</code></strong>：你的云服务器<strong>公网 IP 地址</strong>。</li>
<li><strong><code>SERVER_USER</code></strong>：用于 SSH 登录的用户名（例如 <code>root</code>、<code>ubuntu</code> 或你在服务器上使用的用户名）。</li>
<li><strong><code>SSH_PRIVATE_KEY</code></strong>：粘贴你刚刚复制的<strong>完整私钥内容</strong>（包括 <code>-----BEGIN OPENSSH PRIVATE KEY-----</code> 和 <code>-----END OPENSSH PRIVATE KEY-----</code> 行）。</li>
</ul></li>
</ol>

<blockquote>
<p><strong>提示</strong>：如果你的网站构建时需要环境变量（如 <code>NUXT_PUBLIC_API_BASE</code>），请一并添加到 Secrets 中，后续会在构建步骤使用。</p>
</blockquote>

<hr>

<h2 id="第三部分-创建-github-actions-工作流">⚙️ 第三部分：创建 GitHub Actions 工作流</h2>

<p>这是自动化的“大脑”。在你的项目根目录创建文件：<code>.github/workflows/deploy.yml</code></p>

<pre><code class="language-yaml">name: Deploy to Production

on:
  push:
    branches: [main] # 仅在推送到 main 分支时触发

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      # 1. 拉取代码
      - name: Checkout code
        uses: actions/checkout@v4

      # 2. 设置 Node.js 环境 (以 Nuxt 项目为例)
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &quot;24&quot; # 使用你项目所需的 Node 版本
          cache: &quot;pnpm&quot; # 启用依赖缓存，加速构建

      # 3. 安装依赖
      - name: Install dependencies
        run: pnpm install

      # 4. 构建静态网站（如需环境变量，通过 env 传入）
      - name: Build
        run: pnpm run generate # 或 pnpm run build（取决于项目配置）
        env:
          # 从 GitHub Secrets 读取构建所需变量
          NUXT_PUBLIC_API_BASE: ${{ secrets.NUXT_PUBLIC_API_BASE }}
          # 可根据需要添加更多变量

      # 5. 将构建产物同步到云服务器
      - name: Deploy to Server via Rsync
        uses: burnett01/rsync-deployments@7.0.1
        with:
          switches: -avz --delete # 递归、压缩、同步删除（保持两端完全一致）
          path: .output/public/ # Nuxt 静态文件输出目录
          remote_path: /var/www/my-site/ # 服务器目标目录，必须与 Caddyfile 中配置一致
          remote_host: ${{ secrets.SERVER_HOST }}
          remote_user: ${{ secrets.SERVER_USER }}
          remote_key: ${{ secrets.SSH_PRIVATE_KEY }}
</code></pre>

<h3 id="关键配置说明">关键配置说明</h3>

<ul>
<li><code>path</code>: 你的静态网站构建输出目录。对于其他框架：

<ul>
<li>VitePress: <code>docs/.vitepress/dist/</code></li>
<li>Next.js (SSG): <code>out/</code></li>
<li>Vue CLI: <code>dist/</code></li>
<li>Hugo: <code>public/</code></li>
</ul></li>
<li><code>remote_path</code>: 必须与服务器上 Caddy 配置的 <code>root</code> 目录完全一致。</li>
<li><code>switches: --delete</code>: 确保服务器上的文件是构建结果的精确镜像，自动删除多余文件。</li>
<li>如果构建过程需要环境变量，务必在 <code>Build</code> 步骤的 <code>env</code> 中传入，并在 GitHub Secrets 中预先定义。</li>
</ul>

<hr>

<h2 id="第四部分-触发首次部署与验证">🧪 第四部分：触发首次部署与验证</h2>

<h3 id="4-1-提交并推送代码">4.1 提交并推送代码</h3>

<p>将工作流配置文件添加到 Git 并推送到仓库，触发首次自动化部署。</p>

<pre><code class="language-bash">git add .github/workflows/deploy.yml
git commit -m &quot;feat: 添加自动化部署工作流&quot;
git push origin main
</code></pre>

<h3 id="4-2-监控部署过程">4.2 监控部署过程</h3>

<ol>
<li>进入你的 GitHub 仓库，点击 <strong>Actions</strong> 标签页。</li>
<li>你会看到名为 “Deploy to Production” 的工作流正在运行。</li>
<li>点击进入，可以实时查看每个步骤的日志。</li>
<li>当所有步骤显示绿色对勾（✅），表示部署成功。</li>
</ol>

<h3 id="4-3-验证网站">4.3 验证网站</h3>

<ul>
<li>打开浏览器，访问你的域名（如 <code>https://example.com</code>）。</li>
<li>如果使用 IP，访问 <code>http://你的服务器IP</code>。</li>
<li>你应该能看到部署的网站，并且地址栏显示<strong>安全的 HTTPS 锁标</strong>（如果使用了域名）。</li>
</ul>

<hr>

<h2 id="第五部分-高级配置与问题排查">🔧 第五部分：高级配置与问题排查</h2>

<h3 id="5-1-处理静态资源-图标-图片">5.1 处理静态资源（图标、图片）</h3>

<p>确保项目的<strong>静态资源</strong>（如 <code>favicon.ico</code>、图片）放在正确的目录：</p>

<ul>
<li><strong>Nuxt <sup>3</sup>&frasl;<sub>4</sub></strong>: <code>/public/</code> 目录</li>
<li><strong>Nuxt 2</strong>: <code>/static/</code> 目录</li>
<li>构建后，这些文件会自动复制到 <code>.output/public/</code> 下。</li>
</ul>

<h4 id="推荐做法">推荐做法</h4>

<p>在 <code>public/</code> 下创建子目录（如 <code>public/icons/</code>, <code>public/images/</code>）进行分类管理。</p>

<h3 id="5-2-关键问题排查清单">5.2 关键问题排查清单</h3>

<table>
<thead>
<tr>
<th>现象</th>
<th>可能原因</th>
<th>解决方案</th>
</tr>
</thead>

<tbody>
<tr>
<td><strong>Actions 日志卡在 SSH 连接</strong></td>
<td>1. SSH 密钥格式错误 <br>2. 安全组未开放 22 端口 <br>3. 服务器 <code>sshd_config</code> 配置限制</td>
<td>1. 检查私钥格式，确保在 GitHub Secrets 中完整、多行 <br>2. 检查阿里云安全组入方向规则 <br>3. 检查服务器 <code>/etc/ssh/sshd_config</code> 中的 <code>PermitRootLogin</code> 和 <code>AllowUsers</code> 设置</td>
</tr>

<tr>
<td><strong>网站可以 HTTP 访问，但 HTTPS 报错</strong></td>
<td>1. 域名 DNS 解析未生效或错误 <br>2. 安全组未开放 443 端口</td>
<td>1. 运行 <code>nslookup yourdomain.com</code> 检查 DNS 解析 <br>2. 检查安全组 443 端口规则</td>
</tr>

<tr>
<td><strong>网站显示 “404 Not Found”</strong></td>
<td>1. Caddy <code>root</code> 目录配置错误 <br>2. 文件未成功同步到服务器</td>
<td>1. 核对 <code>/etc/caddy/Caddyfile</code> 中的 <code>root</code> 路径与 <code>deploy.yml</code> 中的 <code>remote_path</code> <br>2. 登录服务器检查 <code>/var/www/my-site/</code> 目录下是否有文件</td>
</tr>

<tr>
<td><strong>构建失败 (Lint/Type Error)</strong></td>
<td>代码检查或类型错误</td>
<td>1. 本地运行 <code>pnpm run lint</code> 和 <code>pnpm run typecheck</code> 修复错误 <br>2. 或暂时在 <code>deploy.yml</code> 中注释掉相关检查步骤</td>
</tr>
</tbody>
</table>

<h3 id="5-3-查看服务器日志">5.3 查看服务器日志</h3>

<p>当遇到问题时，服务器日志是寻找线索的黄金位置。</p>

<pre><code class="language-bash"># 查看 Caddy 实时日志
sudo journalctl -u caddy -f

# 查看 SSH 认证日志（排查连接问题）
sudo tail -f /var/log/auth.log
</code></pre>

<hr>

<h2 id="总结-你现在拥有了什么">📈 总结：你现在拥有了什么？</h2>

<p>通过本教程，你已成功搭建了一套<strong>完全自动化、可追溯、可回滚</strong>的现代静态网站部署流水线：</p>

<ol>
<li><strong>自动化</strong>：只需 <code>git push</code>，后续所有流程自动完成。</li>
<li><strong>零配置 HTTPS</strong>：Caddy 自动管理 SSL 证书的申请和续期。</li>
<li><strong>环境一致</strong>：每次构建都在全新的 GitHub 运行器中进行，杜绝“在我机器上好好的”问题。</li>
<li><strong>安全可靠</strong>：基于 SSH 密钥认证，密钥安全存储在 GitHub Secrets。</li>
<li><strong>可回滚</strong>：如需回滚，只需在 Git 中检出旧版本并推送，Actions 会自动将服务器文件同步至旧状态。</li>
</ol>

<p><strong>从此，你可以专注于本地开发，将构建、测试、发布的重复劳动全部交给自动化流程。</strong></p>
]]></content:encoded>
      <description><![CDATA[专注于纯前端资源的自动化发布，利用 Caddy 自动 HTTPS 和 SPA 路由支持，实现“推送即发布”。]]></description>
      <category><![CDATA[Caddy]]></category>
      <category><![CDATA[CI/CD]]></category>
      <dc:relation><![CDATA[series:deployment]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt i18n 的 `$tm` 函数：环境差异问题与解决方案]]></title>
      <link>https://moongate.top/docs/nuxt-i18n-tm-function-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-i18n-tm-function-guide</guid>
      <pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="核心问题">核心问题</h2>

<p>在 Nuxt 4 + <code>@nuxtjs/i18n</code> v10 项目中，使用 <code>$tm</code> 或 <code>tm()</code> 获取结构化翻译数据（如数组、对象）时，可能会遇到：</p>

<ul>
<li><strong>开发环境 (<code>nuxt dev</code>)</strong>：一切正常，模板渲染正确。</li>
<li><strong>生产环境 (<code>nuxt build</code> 后运行)</strong>：报错 <code>Cannot read properties of undefined (reading 'source')</code>，或页面渲染异常。</li>
</ul>

<h2 id="问题根源">问题根源</h2>

<p>问题的根本原因在于 <strong>i18n 模块在开发环境和生产环境下对语言文件的编译处理方式不同</strong>：</p>

<ul>
<li><strong>开发环境</strong>：为了支持热更新和源码映射，模块会保留语言文件中的原始结构信息，字符串值被包装为 <code>{ loc: { source: &quot;实际文本&quot; } }</code> 的形式。</li>
<li><strong>生产环境</strong>：为了减小体积和提升性能，模块会移除这些包装，直接输出纯字符串值。</li>
</ul>

<p>这种差异导致在模板中访问数据时，如果写死了开发环境下的访问路径（例如 <code>item.name.loc.source</code>），生产环境就会因找不到 <code>loc</code> 属性而报错。</p>

<h2 id="解决方案">解决方案</h2>

<h3 id="方案一-快速修补-适合临时修复">方案一：快速修补（适合临时修复）</h3>

<p>在模板中根据环境动态选择访问路径：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div v-for=&quot;item in tm('navigationBar')&quot; :key=&quot;item.id&quot;&gt;
    &lt;NuxtLink
      :to=&quot;isDev ? item.link?.loc?.source : item.link&quot;
      rel=&quot;noopener noreferrer&quot;
    &gt;
      {{ isDev ? item.name?.loc?.source : item.name }}
    &lt;/NuxtLink&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
const isDev = import.meta.env.DEV
const { tm } = useI18n()
&lt;/script&gt;
</code></pre>

<p><strong>优点</strong>：直接了当，改动最小。</p>

<p><strong>缺点</strong>：每个使用 <code>tm</code> 的地方都要写判断，代码冗余。</p>

<hr>

<h3 id="方案二-封装统一适配函数-推荐">方案二：封装统一适配函数（推荐）</h3>

<p>创建一个组合式函数，自动处理环境差异，让模板代码保持简洁。</p>

<pre><code class="language-ts">// composables/useI18nSafe.ts
import { useI18n } from &quot;vue-i18n&quot;

/**
 * 递归提取开发环境下的实际值（移除 loc.source 包装）
 */
function extractValue(value: any): any {
  if (!value || typeof value !== &quot;object&quot;) return value

  // 处理被包装的字符串（开发环境特有）
  if (value.loc?.source !== undefined) {
    return value.loc.source
  }

  // 处理数组
  if (Array.isArray(value)) {
    return value.map(extractValue)
  }

  // 处理对象
  const result: Record&lt;string, any&gt; = {}
  for (const key in value) {
    result[key] = extractValue(value[key])
  }
  return result
}

export function useI18nSafe() {
  const { tm: originalTm, ...rest } = useI18n()

  const tm = (key: string) =&gt; {
    const value = originalTm(key)
    // 仅开发环境需要提取，生产环境直接返回
    if (import.meta.env.DEV) {
      return extractValue(value)
    }
    return value
  }

  return { tm, ...rest }
}
</code></pre>

<p>在组件中使用：</p>

<pre><code class="language-vue">&lt;script setup&gt;
const { tm } = useI18nSafe()
&lt;/script&gt;

&lt;template&gt;
  &lt;div v-for=&quot;item in tm('navigationBar')&quot; :key=&quot;item.id&quot;&gt;
    &lt;NuxtLink :to=&quot;item.link&quot; rel=&quot;noopener noreferrer&quot;&gt;{{
      item.name
    }}&lt;/NuxtLink&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>

<p><strong>优点</strong>：模板代码与生产环境完全一致，无环境感知，维护简单。</p>

<p><strong>缺点</strong>：需要额外封装，但一次投入长期受益。</p>

<hr>

<h2 id="注意事项">注意事项</h2>

<ol>
<li><p><strong>仅字符串字段受影响</strong><br>
数字、布尔值、数组等类型不会被包装，因此无需特殊处理。</p></li>

<li><p><strong>递归提取</strong><br>
方案二中的 <code>extractValue</code> 会递归遍历所有层级，可处理深层嵌套对象。</p></li>

<li><p><strong>性能</strong><br>
开发环境下会有微小递归开销，不影响生产环境。</p></li>

<li><p><strong>该问题在 v10 中依然存在</strong><br>
不要误以为 v10 已修复。只要 i18n 模块为了开发体验保留 AST 信息，这种差异就可能存在。</p></li>
</ol>

<h2 id="总结">总结</h2>

<p><code>$tm</code> 环境差异是 <code>@nuxtjs/i18n</code> 模块为了兼顾开发体验和生产优化而产生的副作用。通过封装一个环境自适应的 <code>useI18nSafe</code> 组合式函数，可以优雅地解决此问题，让代码在不同环境下都能稳定运行。</p>
]]></content:encoded>
      <description><![CDATA[介绍了 Nuxt.js 项目中使用 @nuxtjs/i18n 模块的 $tm 函数（或组合式 API 中的 tm()）时，一个常见的问题是：在开发环境 (nuxt dev) 下运行正常的代码，在生产环境构建 (nuxt build) 后运行会报错或渲染异常。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[i18n]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:relation><![CDATA[series:i18n]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt Content + i18n终极集成方案：一套内容支持多语言的完整实现]]></title>
      <link>https://moongate.top/docs/nuxt-content-i18n-ultimate-integration</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-content-i18n-ultimate-integration</guid>
      <pubDate>Mon, 29 Dec 2025 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="概述">概述</h2>

<p>在构建多语言技术文档站点时，我们常面临一个矛盾：<strong>内容维护成本</strong>与<strong>多语言用户体验</strong>如何兼得？官方方案往往建议为每种语言维护独立的文档集合，但这对于独立开发者或小团队来说负担沉重。</p>

<p>本文介绍一种创新方案：<strong>只维护一套核心文档（如英文或中文），利用 <code>@nuxtjs/i18n</code> 的路由前缀功能，为用户提供完整的多语言界面体验</strong>。访问 <code>/en/article</code> 与 <code>/zh_cn/article</code> 将显示相同的文档内容，但界面语言、导航菜单等将根据URL前缀自动切换。</p>

<p>核心优势：</p>

<ul>
<li><strong>维护极简</strong>：只需维护单一语言的内容源</li>
<li><strong>体验完整</strong>：用户仍能获得地址栏、导航、界面完全本地化的体验</li>
<li><strong>技术解耦</strong>：Content 管内容，i18n 管路由与界面，各司其职</li>
<li><strong>无缝扩展</strong>：未来可为特定文档添加翻译，无需改造架构</li>
</ul>

<hr>

<h2 id="问题背景-当-content-遇上-i18n">问题背景：当 Content 遇上 i18n</h2>

<p><code>@nuxt/content</code> 模块本身提供了基础的国际化支持，但其设计初衷是<strong>内容与语言强绑定</strong>：</p>

<ul>
<li>每个语言独立的文件夹（<code>/content/en/</code>, <code>/content/zh_cn/</code>）</li>
<li>每个语言独立的内容集合配置</li>
<li>自动回退机制：当 <code>/es/article</code> 不存在时，跳回默认语言</li>
</ul>

<p>但这带来了两个实际问题：</p>

<ol>
<li><strong>内容同步压力</strong>：任何更新都需要在所有语言副本中重复</li>
<li><strong>架构复杂</strong>：需要配置多个内容集合，查询时需要额外逻辑</li>
</ol>

<p>而我们真正的需求往往是：</p>

<blockquote>
<p>“我只有精力维护一套技术文档，但希望网站支持多语言界面。”</p>
</blockquote>

<hr>

<h2 id="解决方案-路径转换中间层">解决方案：路径转换中间层</h2>

<p>核心思路是建立一个<strong>路径转换层</strong>，在用户访问时动态处理 URL：</p>

<pre><code class="language-text">graph LR
    A[用户访问&lt;br&gt;/ja/docs/nuxt-guide] --&gt; B{i18n 模块};
    B --&gt; C[识别语言前缀 ja];
    C --&gt; D[移除前缀 /ja];
    D --&gt; E[Content 查询 /docs/nuxt-guide];
    E --&gt; F[返回统一内容];
    F --&gt; G[界面元素使用 ja 语言包];
    G --&gt; H[用户看到日文界面&lt;br&gt;英文内容];
</code></pre>

<h3 id="实现细节-稳定查询路径">实现细节：稳定查询路径</h3>

<p>以下是基于 Vue 3 与 Nuxt 3 的完整实现示例：</p>

<blockquote>
<p>路由层处理</p>
</blockquote>

<pre><code class="language-vue">&lt;UBlogPost
  v-for=&quot;item in fiterArticles&quot;
  :key=&quot;item.id&quot;
  :title=&quot;item.title&quot;
  :description=&quot;item.description&quot;
  :date=&quot;item.meta.date&quot;
  class=&quot;card cursor-pointer&quot;
  @click=&quot;navigateTo(locale === 'zh_cn' ? item.path : `/${locale}${item.path}`)&quot;
/&gt;
</code></pre>

<blockquote>
<p>内容层：由 @nuxt/content 处理（移除语言前缀）</p>
</blockquote>

<pre><code class="language-typescript">&lt;script lang=&quot;ts&quot; setup&gt;
import { withLeadingSlash } from &quot;ufo&quot;;
const { locale } = useI18n();
const route = useRoute();

// 核心：移除语言前缀，得到原始路径
// 例如：/en/docs/welcome -&gt; /docs/welcome
const slug = computed(() =&gt; {
  const path = withLeadingSlash(String(route.params.slug || &quot;/&quot;));
  // 移除语言前缀部分
  return path.replace(new RegExp(`^/(${locale.value})`), &quot;&quot;) || &quot;/&quot;;
});

// 稳定查询：永远只查询 'docs' 这个集合
const { data: page } = await useAsyncData(
  route.path,
  () =&gt; {
    return queryCollection(&quot;docs&quot;).path(`/docs${slug.value}`).first();
    // 注意：查询路径需要加上 '/docs' 前缀，以匹配 content/docs/ 下的文件
  },
  {
    // 设置 transform 确保数据一致性
    transform: (data) =&gt; {
      if (!data) return null;
      return data;
    },
  },
);
&lt;/script&gt;
</code></pre>

<h2 id="推荐的目录结构">📁 推荐的目录结构</h2>

<p>保持您的单语言内容结构：</p>

<pre><code class="language-text">content/
  docs/
    nuxt-content-guide.md
    getting-started.md
    i18n-config.md
  about.md
</code></pre>

<blockquote>
<p>docs文件夹是我专门为项目建立的，无需跟我一模一样</p>
</blockquote>

<h2 id="路由映射关系">🔄 路由映射关系</h2>

<table>
<thead>
<tr>
<th>访问 URL</th>
<th>i18n 处理</th>
<th>Content 查询</th>
<th>实际文件</th>
</tr>
</thead>

<tbody>
<tr>
<td><code>/zh_cn/docs/nuxt-guide</code></td>
<td>识别为中文</td>
<td><code>/docs/nuxt-guide</code></td>
<td><code>content/docs/nuxt-guide.md</code></td>
</tr>

<tr>
<td><code>/en/docs/nuxt-guide</code></td>
<td>识别为英文</td>
<td><code>/docs/nuxt-guide</code></td>
<td><code>content/docs/nuxt-guide.md</code></td>
</tr>

<tr>
<td><code>/ja/docs/nuxt-guide</code></td>
<td>识别为日文</td>
<td><code>/docs/nuxt-guide</code></td>
<td><code>content/docs/nuxt-guide.md</code></td>
</tr>
</tbody>
</table>

<h2 id="总结">总结</h2>

<p>通过 <code>@nuxtjs/i18n</code> 的路由前缀与 <code>@nuxt/content</code> 的路径转换相结合，我们实现了：</p>

<ol>
<li><strong>维护成本最小化</strong> - 单一内容源</li>
<li><strong>用户体验最大化</strong> - 完整的多语言界面支持</li>
<li><strong>查询稳定性</strong> - 使用固定的内容集合查询，避免路径解析问题</li>
</ol>

<p>这种模式特别适合：</p>

<ul>
<li>技术文档、API参考</li>
<li>个人技术博客</li>
<li>初创公司产品文档</li>
<li>任何需要快速支持多语言但翻译资源有限的场景</li>
</ul>

<hr>

<p><em>本文采用所述方案编写，访问 <code>/en/docs/single-content-multilingual-routes</code> 或 <code>/zh_cn/docs/single-content-multilingual-routes</code> 可体验实际效果。</em></p>
]]></content:encoded>
      <description><![CDATA[本文介绍一种创新方案：只维护一套核心文档（如英文或中文），利用 @nuxtjs/i18n 的路由前缀功能，为用户提供完整的多语言界面体验。访问 /en/article 与 /zh_cn/article 将显示相同的文档内容，但界面语言、导航菜单等将根据URL前缀自动切换。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[i18n]]></category>
      <dc:relation><![CDATA[series:i18n]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[解决Nuxt Content渲染问题：从基础配置到渲染显示完整指南]]></title>
      <link>https://moongate.top/docs/nuxt-content-config-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-content-config-guide</guid>
      <pubDate>Sun, 28 Dec 2025 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="概述">概述</h2>

<p>这篇文档记录了我从零开始配置 Nuxt Content 模块，渲染 Markdown 内容的完整过程。如果你也厌倦了在配置上耗费数小时却一行业务代码都没写的挫败感，这篇实战指南或许能帮你少走弯路。</p>

<blockquote>
<p><strong>适用版本</strong>：Nuxt Content v3<br>
如果你使用其他版本，核心思路仍可参考，但具体行为可能略有差异。</p>
</blockquote>

<h2 id="1-环境与项目初始化">1. 环境与项目初始化</h2>

<h3 id="1-1-创建-nuxt-项目">1.1 创建 Nuxt 项目</h3>

<pre><code class="language-bash"># 使用官方脚手架创建项目
pnpm create nuxt &lt;My-Project&gt;
cd &lt;My-Project&gt;

# 安装基础依赖（如果你使用 pnpm）
pnpm install
</code></pre>

<h3 id="1-2-安装多个必备模块">1.2 安装多个必备模块</h3>

<pre><code class="language-bash"># 安装 Nuxt Content 模块
pnpm add @nuxt/content

# 安装 better-sqlite3 模块
pnpm add better-sqlite3
</code></pre>

<h3 id="1-3-处理可能存在的better-sqlite3模块的兼容问题">1.3 处理可能存在的better-sqlite3模块的兼容问题</h3>

<p>在安装 <code>@nuxt/content</code> 时，其依赖的 <code>better-sqlite3</code> 是一个原生模块，在使用 pnpm 管理依赖时可能会遇到二进制文件路径解析问题。以下是两种解决方案，你可以根据项目环境和需求选择其中一种。</p>

<hr>

<h4 id="方案一-通过-pnpm-配置与重建-推荐大多数项目">方案一：通过 pnpm 配置与重建（推荐大多数项目）</h4>

<p>此方案通过显式允许构建 <code>better-sqlite3</code> 并重建其二进制文件，确保模块在 pnpm 的严格模式下正常工作。</p>

<ol>
<li><strong>创建或修改 <code>pnpm-workspace.yaml</code></strong><br>
在项目根目录创建该文件，内容如下：</li>
</ol>

<pre><code class="language-yaml">   onlyBuiltDependencies:
     - better-sqlite3
</code></pre>

<p>或者直接在安装时使用 <code>--allow-build</code> 参数，pnpm 会自动完成配置：</p>

<pre><code class="language-bash">   pnpm add better-sqlite3 --allow-build=better-sqlite3
</code></pre>

<ol>
<li><strong>重建 better-sqlite3</strong><br>
执行以下命令强制重新编译原生模块：</li>
</ol>

<pre><code class="language-bash">   pnpm rebuild better-sqlite3
</code></pre>

<ol>
<li><strong>启动开发服务器验证</strong></li>
</ol>

<pre><code class="language-bash">   pnpm run dev
</code></pre>

<blockquote>
<p>此方案保留了 pnpm 的所有优势，同时解决了原生模块的兼容性问题，适用于大多数 Nuxt 项目。</p>
</blockquote>

<hr>

<h4 id="方案二-启用-nuxt-content-的原生-sqlite-支持-需要-node-js-v22-5-0">方案二：启用 Nuxt Content 的原生 SQLite 支持（需要 Node.js v22.5.0+）</h4>

<p>从 <code>@nuxt/content</code> 的某个版本开始（具体请查阅对应版本的文档），你可以通过配置 <code>experimental.nativeSqlite</code> 选项直接启用原生 SQLite 绑定，无需手动处理 <code>better-sqlite3</code> 的 pnpm 兼容性问题。</p>

<ol>
<li><strong>确保 Node.js 版本 ≥ v22.5.0</strong><br>
检查当前 Node 版本：</li>
</ol>

<pre><code class="language-bash">   node -v
</code></pre>

<p>如果版本过低，请升级。</p>

<ol>
<li><strong>在 <code>nuxt.config.ts</code> 中添加配置</strong></li>
</ol>

<pre><code class="language-typescript">   export default defineNuxtConfig({
     modules: [&quot;@nuxt/content&quot;],
     content: {
       experimental: {
         nativeSqlite: true, // 启用原生 SQLite 支持
       },
     },
   });
</code></pre>

<ol>
<li><strong>启动项目</strong></li>
</ol>

<pre><code class="language-bash">   pnpm run dev
</code></pre>

<p>启用此选项后，Nuxt Content 会使用原生的 <code>better-sqlite3</code> 实现，通常在服务器端性能和稳定性上更优，尤其适合生产环境。但请注意，此功能为实验性，可能需要配合特定版本的 Nuxt Content 使用。</p>

<hr>

<h5 id="选择">选择</h5>

<p>方案一适用于任何 Node 版本，对 pnpm 项目通用；方案二更简洁，但需要较高版本的 Node 环境和对实验性特性的接受度。请根据实际情况选择。</p>

<h2 id="2-基础配置">2. 基础配置</h2>

<h3 id="2-1-配置-nuxt-config-ts">2.1 配置 <code>nuxt.config.ts</code></h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;@nuxt/content&quot;],
});
</code></pre>

<h3 id="2-2-创建内容目录和第一篇文档">2.2 创建内容目录和第一篇文档</h3>

<p>在项目根目录创建 <code>content</code> 文件夹，然后创建第一篇文档，文件暂命名为home.md</p>

<pre><code class="language-markdown"># 欢迎来到我的博客

这是我的第一篇使用 **Nuxt Content** 构建的文档。

## 特性亮点

- ✅ 支持 Markdown 语法
- ✅ 内置代码高亮
- ✅ 前端框架无缝集成

## 代码示例

</code></pre>

<pre><code class="language-javascript">// 这是一个 JavaScript 示例
export default function greet(name) {
  console.log(`Hello, ${name}!`);
  return `Welcome to ${name}'s blog`;
}
</code></pre>

<h2 id="3-创建文档渲染页面">3. 创建文档渲染页面</h2>

<pre><code class="language-vue">&lt;!-- 在app/pages的目录下创建文件名为[...slug].vue的文件，将以下代码粘贴到此文件中 --&gt;
&lt;script setup lang=&quot;ts&quot;&gt;
const route = useRoute();

const { data: page } = await useAsyncData(&quot;page-&quot; + route.path, () =&gt; {
  return queryCollection(&quot;content&quot;).path(route.path).first();
});

console.log(page.value);

if (!page.value) {
  throw createError({
    statusCode: 404,
    statusMessage: &quot;Page not found&quot;,
    fatal: true,
  });
}
&lt;/script&gt;

&lt;template&gt;
  &lt;ContentRenderer v-if=&quot;page&quot; :value=&quot;page&quot; /&gt;
&lt;/template&gt;
</code></pre>

<h2 id="总结">总结</h2>

<h3 id="1-配置心得">1. 配置心得</h3>

<ol>
<li><strong>版本一致性</strong>：确保所有依赖版本兼容，特别是 Nuxt、Content 模块</li>
<li><strong>渐进式配置</strong>：不要一次性配置所有功能，先确保基础渲染正常，再逐步添加高级功能。</li>
<li><strong>善用官方文档</strong>：遇到问题时，首先查看各模块的官方文档，注意版本差异。</li>
</ol>

<h3 id="2-避坑指南">2. 避坑指南</h3>

<ul>
<li><strong>问题</strong>：<code>@nuxt/content</code> 与 <code>better-sqlite3</code> 原生模块在 pnpm 环境下冲突，导致安装或启动失败。

<ul>
<li><strong>原因</strong>：pnpm 的严格链接模式可能导致原生模块的二进制文件无法被正确加载，引发 <code>better-sqlite3</code> 相关错误。</li>
<li><strong>解决</strong>：有两种方式可处理该问题——

<ul>
<li><strong>方案一</strong>：通过 pnpm 配置显式允许构建并重建模块（详见上文 <strong>1.3 方案一</strong>）。</li>
<li><strong>方案二</strong>：升级 Node.js 至 <strong>v22.5.0 或更高</strong>，并在 <code>nuxt.config.ts</code> 中添加 <code>experimental: { nativeSqlite: true }</code>。此配置让 Nuxt Content 直接使用原生的 SQLite 绑定，从根本上避免路径解析问题，同时提升生产环境性能。<strong>推荐使用此方案</strong>，它同时解决了下文的生产环境数据丢失问题。</li>
</ul></li>
</ul></li>
<li><strong>问题</strong>：生产环境使用 <code>useAsyncData</code> 渲染内容时，数据偶发无法获取，刷新后内容丢失。

<ul>
<li><strong>原因</strong>：默认情况下，Nuxt Content 在服务端可能回退到 JavaScript 实现的 SQLite，其性能不足以应对生产环境的并发请求，导致数据库访问失败。</li>
<li><strong>解决</strong>：确保 Node.js 版本 <strong>≥ v22.5.0</strong>，并在 <code>nuxt.config.ts</code> 的 <code>content</code> 配置中启用 <code>experimental: { nativeSqlite: true }</code>。该选项强制使用原生的 <code>better-sqlite3</code>，大幅提升数据库操作的稳定性和性能，彻底解决数据丢失问题。</li>
</ul></li>
</ul>

<blockquote>
<p><strong>小结</strong>：两个常见问题的根本原因都与 SQLite 的实现方式有关。通过升级 Node 版本并开启 <code>experimental.nativeSqlite</code>，可以同时解决原生模块兼容性与生产环境数据丢失的问题，是当前最简洁有效的做法。</p>
</blockquote>

<h3 id="3-性能建议">3. 性能建议</h3>

<ol>
<li><strong>代码分割</strong>：利用 Nuxt 的自动代码分割功能</li>
<li><strong>图片优化</strong>：使用 <code>@nuxt/image</code> 模块优化内容中的图片</li>
</ol>

<hr>

<blockquote>
<p><strong>写在最后</strong>：虽然配置过程可能充满挑战，但一旦完成，你将获得一个强大、现代且完全可控的内容管理系统。每一次配置问题的解决，都是对现代前端工具链理解的深化。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[记录了我从零开始配置 @nuxt/content 模块，渲染 Markdown 内容的完整过程。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:relation><![CDATA[series:performance]]></dc:relation>
    </item>

    <item>
      <title><![CDATA[Nuxt 4 国际化(i18n)完整配置：从基础设置到高级优化]]></title>
      <link>https://moongate.top/docs/nuxt-i18n-config-guide</link>
      <guid isPermaLink="true">https://moongate.top/docs/nuxt-i18n-config-guide</guid>
      <pubDate>Thu, 11 Dec 2025 00:00:00 GMT</pubDate>
      <content:encoded><![CDATA[<h2 id="一-快速开始">一、快速开始</h2>

<h3 id="1-1-安装模块">1.1 安装模块</h3>

<pre><code class="language-bash">pnpm install @nuxt/i18n
</code></pre>

<h3 id="1-2-基础配置-nuxt-config-ts">1.2 基础配置 (<code>nuxt.config.ts</code>)</h3>

<pre><code class="language-typescript">export default defineNuxtConfig({
  modules: [&quot;@nuxtjs/i18n&quot;],

  i18n: {
    // 语言环境配置（核心）
    locales: [
      {
        code: &quot;zh_cn&quot;, // 程序内部标识符（URL路径使用）
        name: &quot;简体中文&quot;, // 显示名称
        language: &quot;zh-CN&quot;, // 用于HTML lang属性的标准语言标签
        file: &quot;zh_cn.json&quot;, // 对应的语言文件
      },
      {
        code: &quot;en&quot;,
        name: &quot;English&quot;,
        language: &quot;en-US&quot;,
        file: &quot;en-US.json&quot;,
      },
      {
        code: &quot;ja&quot;,
        name: &quot;日本語&quot;,
        language: &quot;ja-JP&quot;,
        file: &quot;ja-JP.json&quot;,
      },
    ],

    // 默认语言设置（必须与某个code完全匹配）
    defaultLocale: &quot;zh_cn&quot;,

    // 语言文件目录
    langDir: &quot;locales&quot;,

    // 路由策略
    strategy: &quot;prefix_except_default&quot;, // 推荐：默认语言无前缀

    // 浏览器语言检测
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: &quot;i18n_redirected&quot;,
      redirectOn: &quot;root&quot;,
    },
  },
})
</code></pre>

<h3 id="1-3-语言文件结构">1.3 语言文件结构</h3>

<p>创建语言文件目录和文件：</p>

<pre><code class="language-text">project-root/
├── i18n/
│   └── locales/
│       ├── zh_cn.json    # 简体中文
│       ├── en.json       # 英文
│       └── ja.json       # 日文
├── nuxt.config.ts
└── app.vue
</code></pre>

<h3 id="1-4-创建语言文件">1.4 创建语言文件</h3>

<p>以 <code>locales/zh_cn.json</code> 为例（en、ja 文件结构相同，仅内容翻译不同）：</p>

<pre><code class="language-json">{
  &quot;welcome&quot;: &quot;欢迎使用我们的应用&quot;,
  &quot;about&quot;: &quot;关于我们&quot;,
  &quot;user&quot;: {
    &quot;profile&quot;: &quot;用户资料&quot;,
    &quot;settings&quot;: &quot;设置&quot;
  }
}
</code></pre>

<h3 id="1-5-动态区域与方向设置">1.5 动态区域与方向设置</h3>

<p>根据当前语言动态设置 UI 区域（locale）和 HTML 的 <code>lang</code>、<code>dir</code> 属性：</p>

<pre><code class="language-vue">&lt;script setup lang=&quot;ts&quot;&gt;
import * as locales from &quot;@nuxt/ui/locale&quot;

const { locale } = useI18n()

const lang = computed(() =&gt; locales[locale.value].code)
const dir = computed(() =&gt; locales[locale.value].dir)

useHead({
  htmlAttrs: {
    lang,
    dir,
  },
})
&lt;/script&gt;

&lt;template&gt;
  &lt;UApp :locale=&quot;locales[locale]&quot;&gt;
    &lt;NuxtPage /&gt;
  &lt;/UApp&gt;
&lt;/template&gt;
</code></pre>

<h2 id="二-中文配置的特别痛点与解决方案">二、中文配置的特别痛点与解决方案</h2>

<h3 id="2-1-痛点一-语言标识符不一致与默认语言配置错误">2.1 痛点一：语言标识符不一致与默认语言配置错误</h3>

<p><strong>问题</strong>：中文有多种标识符格式（<code>zh</code>、<code>zh-CN</code>、<code>zh_CN</code>、<code>zh_cn</code>），容易混淆，导致 <code>defaultLocale</code> 配置不匹配、页面报错。</p>

<p><strong>解决方案</strong>：</p>

<ul>
<li><strong><code>code</code> 字段</strong>：用于URL路径和程序内部标识，推荐使用 <strong><code>zh_cn</code></strong>（全小写下划线）</li>
<li><strong><code>language</code> 字段</strong>：用于HTML <code>lang</code> 属性和SEO，使用标准 <strong><code>zh-CN</code></strong>（连字符格式）</li>
<li><strong><code>defaultLocale</code></strong>：必须与 <code>code</code> 值<strong>完全一致</strong></li>
</ul>

<pre><code class="language-typescript">// 正确的完整示例
locales: [
  { code: 'zh_cn', language: 'zh-CN', file: 'zh_cn.json' }
],
defaultLocale: 'zh_cn', // 必须与上面的code完全相同
strategy: 'prefix_except_default'
</code></pre>

<pre><code class="language-typescript">defaultLocale: &quot;zh_cn&quot; // 正确
defaultLocale: &quot;zh-CN&quot; // 错误！会导致配置不匹配
</code></pre>

<h3 id="2-2-痛点二-语言文件加载失败">2.2 痛点二：语言文件加载失败</h3>

<p><strong>问题</strong>：控制台报错 <code>Cannot find module './locales/zh.json'</code>。</p>

<p><strong>解决方案</strong>：</p>

<ol>
<li><strong>检查文件路径</strong>：确认 <code>langDir</code> 配置正确</li>
<li><strong>验证JSON格式</strong>：语言文件必须是<strong>严格有效的JSON</strong></li>
</ol>

<pre><code class="language-json">// 正确
{ &quot;welcome&quot;: &quot;欢迎&quot; }

// 错误（有注释）
{
  // 欢迎语
  &quot;welcome&quot;: &quot;欢迎&quot;
}

// 错误（有尾随逗号）
{ &quot;welcome&quot;: &quot;欢迎&quot;, }
</code></pre>

<h2 id="三-高级配置">三、高级配置</h2>

<h3 id="3-1-子域名国际化-像vue官网一样">3.1 子域名国际化（像Vue官网一样）</h3>

<pre><code class="language-typescript">i18n: {
  locales: [
    {
      code: 'zh_cn',
      domain: 'cn.your-app.com', // 生产环境子域名
      language: 'zh-CN',
      file: 'zh_cn.json'
    },
    {
      code: 'en',
      domain: 'your-app.com', // 主域名作为默认语言
      language: 'en-US',
      file: 'en.json'
    }
  ],
  differentDomains: true, // 启用子域名模式
  defaultLocale: 'en', // 默认语言对应主域名
  detectBrowserLanguage: false // 子域名模式下通常禁用
}
</code></pre>

<h3 id="3-2-翻译占位符-参数插值">3.2 翻译占位符（参数插值）</h3>

<p>在实际项目中，经常需要动态替换翻译文本中的变量，例如&rdquo;共 {count} 条记录&rdquo;。<code>@nuxtjs/i18n</code> 支持在翻译字符串中使用 <code>{key}</code> 占位符，并在组件中传入对应的值。</p>

<h4 id="语言文件示例">语言文件示例</h4>

<p>在 <code>i18n/locales/zh_cn.json</code> 和 <code>i18n/locales/en.json</code> 中定义带占位符的键：</p>

<pre><code class="language-json">// zh_cn.json
{
  &quot;findCount&quot;: &quot;已查询到 {count} 条文档&quot;,
  &quot;greeting&quot;: &quot;你好，{name}！欢迎回来。&quot;,
  &quot;balance&quot;: &quot;当前余额：{amount, number} 元&quot;
}
</code></pre>

<pre><code class="language-json">// en.json
{
  &quot;findCount&quot;: &quot;Found {count} documents&quot;,
  &quot;greeting&quot;: &quot;Hello {name}! Welcome back.&quot;,
  &quot;balance&quot;: &quot;Balance: {amount, number} USD&quot;
}
</code></pre>

<h4 id="组件中使用">组件中使用</h4>

<p>在 Vue 组件中通过 <code>$t</code> 或 <code>t</code> 函数传递参数对象：</p>

<pre><code class="language-vue">&lt;template&gt;
  &lt;div&gt;
    &lt;p&gt;{{ t(&quot;findCount&quot;, { count: totalDocs }) }}&lt;/p&gt;
    &lt;p&gt;{{ t(&quot;greeting&quot;, { name: userName }) }}&lt;/p&gt;
    &lt;p&gt;{{ t(&quot;balance&quot;, { amount: userBalance }) }}&lt;/p&gt;
  &lt;/div&gt;
&lt;/template&gt;

&lt;script setup&gt;
const { t } = useI18n()
const totalDocs = ref(25)
const userName = ref(&quot;张三&quot;)
const userBalance = ref(12345.67)
&lt;/script&gt;
</code></pre>

<h3 id="注意事项">注意事项</h3>

<ul>
<li>占位符键名必须<strong>严格匹配</strong>（区分大小写），如 <code>{count}</code> 不能写成 <code>{Count}</code>。</li>
<li>如果某个占位符未传入值，它会原样输出（如 <code>{count}</code>）。</li>
<li>对于需要复数处理的场景（如&rdquo;1 条评论 / 2 条评论&rdquo;），请使用 <code>vue-i18n</code> 的复数机制，而非手动拼接。</li>
</ul>

<h2 id="总结">总结</h2>

<p><code>@nuxtjs/i18n</code> v10 为 Nuxt 应用提供了强大且灵活的国际化方案，但配置细节繁多，尤其在中文场景下容易踩坑。回顾全文，有几个关键点值得再次强调：</p>

<ol>
<li><p><strong>版本差异巨大</strong><br>
v10 与 v8 不兼容，务必以本文和官方文档为准，摒弃旧版认知。</p></li>

<li><p><strong>语言标识符规范化</strong></p>

<ul>
<li><code>code</code>：全小写下划线（如 <code>zh_cn</code>）</li>
<li><code>language</code>：标准连字符格式（如 <code>zh-CN</code>）</li>
<li><code>defaultLocale</code>：必须与 <code>code</code> <strong>严格一致</strong></li>
</ul></li>

<li><p><strong>语言文件维护</strong><br>
存放目录与 JSON 格式需准确无误，避免注释或尾随逗号。</p></li>

<li><p><strong>占位符参数插值</strong><br>
使用 <code>{key}</code> 配合 <code>t('key', { key: value })</code> 实现动态文本，并支持数字/日期格式化。</p></li>

<li><p><strong>路由策略与子域名</strong><br>
小型项目推荐 <code>prefix_except_default</code>，大型多站点可启用 <code>differentDomains</code>。</p></li>

<li><p><strong>性能与 SEO</strong><br>
通过 <code>useHead</code> 动态设置 <code>lang</code> 和 <code>dir</code> 提升可访问性。</p></li>
</ol>

<blockquote>
<p>国际化的核心目标是让不同语言的用户获得一致的体验，希望本文能帮助你少走弯路，快速构建高质量的 Nuxt 多语言应用。</p>
</blockquote>
]]></content:encoded>
      <description><![CDATA[本文介绍了 @nuxt/i18n 模块的配置方法，并提供了一些常见问题的解决方案。]]></description>
      <category><![CDATA[Nuxt]]></category>
      <category><![CDATA[i18n]]></category>
      <category><![CDATA[Engineering]]></category>
      <category><![CDATA[Performance]]></category>
      <dc:relation><![CDATA[series:i18n]]></dc:relation>
    </item>

  </channel>
</rss>