普通视图

发现新文章,点击刷新页面。
昨天以前首页

我用三道防线封死了 AI 幻觉与人类健忘:Next.js 工程化免疫闭环实战

作者 DiffServ
2026年4月15日 20:17

拒绝 AI 幻觉与人类健忘:Next.js 三重免疫系统实战

前端圈有个巨大的错觉:以为写好了 ESLint 规则、配好了 Prettier,代码质量就有保障了。但当你引入 Cursor、Claude 这些 AI 编程助手后,你会发现一个全新的威胁维度——AI 写代码很快,但它经常悄悄吃掉你最关键的防御逻辑。 一个 <div className="min-h-screen bg-zinc-50"> 被它换成空 Fragment <>,你的移动端背景直接消失,SEO 标签跟着蒸发。等你发现的时候,Google 爬虫已经把废墟抓走了。靠 Code Review 和人类记忆来防这种事?反人类的。必须靠系统防御。

事故现场:静悄悄的线上灾难

事情发生在一个平平无奇的周二下午。

我让 AI 帮我重构两篇新博客文章的页面组件。本地跑得很欢,pnpm dev 一切正常。部署到线上后随手用手机打开一看——背景色没了,页面高度塌陷,和之前几篇文章的风格完全不一致。

排查原因只花了一分钟:

// ❌ AI 生成的版本 — 外层是空 Fragment
return (
  <>
    <Header />
    <article>...</article>
  </>
)

// ✅ 正确版本 — 有完整容器包裹
return (
  <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
    <Header />
    <article>...</article>
  </div>
)

AI 把最外层的 <div> 容器"优化"成了空 Fragment <>。从 JSX 语法上看完全合法,功能上也不报错。但在视觉层面,min-h-screen(最小视口高度)丢了,bg-zinc-50 dark:bg-zinc-950(亮/暗模式背景色)也丢了。移动端一打开就是白底白字或者灰底灰字的灾难现场。

这还不是最可怕的。更可怕的是——如果我当时没拿手机检查,这篇文章就会带着残缺的布局一直躺在生产环境里。 SEO 爬虫抓到的是一个没有规范链接、没有结构化数据的半成品页面。

这不是 Bug,这是一个系统性漏洞。只要还依赖人工检查,迟早会漏。唯一的解法是:把正确的事情变容易,把错误的事情变不可能。

第一道防线:源头清洗(脚手架约束)

问题的根源是什么?手动创建文件。

当你手动新建一个 page.tsx 时,你有两个选择:

  1. 从旧文件复制粘贴 → 可能复制了过时的模板,也可能漏掉新增的规范
  2. 从头手写 → 必然会忘记某些字段

无论哪种选择,都在依赖人类的短期记忆。而短期记忆是最不可靠的东西。

我的解决方案是一个 CLI 脚手架:

pnpm post:new

运行后会交互式提示输入 slug、标题、描述、关键词,然后自动生成一个满配状态page.tsx 骨架:

$ pnpm post:new

🚀 创建新文章(强制 SEO 规范)

文章 slug: triple-immune-system
文章标题: 拒绝 AI 幻觉与人类健忘
文章描述: Next.js 工程化免疫闭环实战
关键词: Next.js, SEO, 工程化, CI/CD, AI编程

✅ 文章创建成功!
📂 路径: apps/web/src/app/blog/triple-immune-system/page.tsx
👉 已自动注入 Canonical, JSON-LD, OpenGraph 等极致 SEO 标签!

生成的模板长这样(核心骨架):

import { Header } from '@/components/Header'
import type { Metadata } from 'next'
import { generateBlogPostingJsonLd } from '@/lib/jsonld'

export const metadata: Metadata = {
  title: '文章标题 - DiffServ Lab',
  description: '文章描述',
  keywords: ['关键词1', '关键词2'],
  alternates: { canonical: '/blog/slug' },          // ← 规范链接
  openGraph: {                                       // ← 社交媒体卡片
    title: '文章标题',
    description: '文章描述',
    url: 'https://diffserv.xyz/blog/slug',
    siteName: 'DiffServ Lab',
    type: 'article',
  },
  twitter: {                                        // ← Twitter Card
    card: 'summary_large_image',
    title: '文章标题',
    description: '文章描述',
  },
}

export default function BlogPost() {
  const jsonLd = generateBlogPostingJsonLd({        // ← 结构化数据
    title: '文章标题',
    description: '文章描述',
    url: 'https://diffserv.xyz/blog/slug',
    datePublished: '2026-04-15',
  })

  return (
    <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">  // ← 容器包裹
      <script type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
      <Header />
      <article className="max-w-3xl mx-auto ... overflow-x-hidden">
        {/* 正文 */}
      </article>
    </div>
  )
}

注意这个模板一次性注入了 5 个关键防御层

防御层 作用 缺失后果
alternates.canonical 告诉搜索引擎这是原始地址 被视为重复内容,权重分散
generateBlogPostingJsonLd BlogPosting 结构化数据 Google 富文本搜索结果出不来
openGraph Facebook/Telegram 分享卡片 分享出去只有光秃秃的链接
twitter Twitter/X 分享卡片 推文没有预览图
min-h-screen + bg-* 移动端容器 高度塌陷、背景断层

核心思想:把正确的事情变容易,把错误的事情变困难。 当你运行一条命令就能拿到满配模板时,没有人会选择从头手写。

但这还不够。脚手架只能保证"新建时正确",无法防止后续修改时的退化。比如某个开发者(或 AI)觉得 Fragment 更简洁,顺手就把外层 div 删掉了。这时候需要第二道防线。

第二道防线:构建时铁闸(正则 + 结构强制拦截)

这是整套系统里最具暴力美学的一环。

我在 package.json 的 build 命令上挂了一个前置钩子:

{
  "scripts": {
    "build": "pnpm post:verify && pnpm --filter './apps/*' build"
  }
}

每次执行 pnpm build(无论是本地打包还是服务器 Docker 构建),都会先跑一遍 verify-seo.js。任何不符合规范的文章,构建直接崩溃

这个校验脚本做了什么?它扫描 blog/ 目录下所有文章的 page.tsx,做 3 类检查

SEO 完整性检查

// === SEO 检查 ===
if (!content.includes('generateBlogPostingJsonLd')) {
  errors.push('[SEO] 缺少 JSON-LD 结构化数据');
}
if (!content.includes('alternates: { canonical:')) {
  errors.push('[SEO] 缺少规范链接 (canonical)');
}
if (!content.includes('openGraph:')) {
  errors.push('[SEO] 缺少 OpenGraph 协议标签');
}

布局结构检查

// === 布局结构检查 ===
if (!content.includes('min-h-screen')) {
  errors.push('[布局] 缺少 min-h-screen 容器,移动端背景/高度会异常');
}
if (!content.includes('bg-zinc-50') || !content.includes('dark:bg-zinc-950')) {
  errors.push('[布局] 缺少 bg-zinc-50 dark:bg-zinc-950 背景色');
}

// 最狠的一条:检测外层是否是空 Fragment <>
const returnMatch = content.match(/return\s*(\s*(<[^>]+>)/);
if (returnMatch && returnMatch[1] === '<>') {
  errors.push('[布局] 外层使用了空 Fragment <>,应使用 <div>');
}

最后这条是精准打击。用正则匹配 return ( 后面的第一个 JSX 标签,如果是 <> 就直接报错。这就是导致那次移动端布局异常的元凶。

你可能会问:为什么不用 AST 解析?因为 Next.js 的构建管线已经有 TypeScript 编译器做语法校验了,我们这道防线的定位是结构约束而非语法分析。正则足够精准地捕获这个高频反模式,零依赖,插入任何项目即生效。AST 解析器引入几十个依赖包,为这一个检查杀鸡用牛刀。

执行效果

当有文章违规时,终端输出极具压迫感:

🔍 开始校验博客规范...

❌ [some-post] 规范检查未通过:
   [SEO] 缺少 JSON-LD 结构化数据 (generateBlogPostingJsonLd)
   [布局] 外层使用了空 Fragment <>,应使用 <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">

✅ [other-post] 全部检查通过

🚫 校验失败!请修复后重试。建议使用 pnpm post:new 创建文章。

然后 process.exit(1) 直接终止进程。Docker 构建失败,CI/CD 流水线变红,部署被无情阻断。

不是 Warning,不是 Hint,是直接枪毙。在生产环境里,宽容就是对用户的残忍。

第三道防线:赛博紧箍咒(CLAUDE.md 规则引擎)

前两道防线已经很强了,但还有一个盲区:AI 编程助手。

现在的主流开发流程是"人机协同"。你用 Cursor、Claude Code、Copilot 写代码,它们很聪明,但也极其狡猾。它们会"自作主张"地帮你"优化"代码——比如把你精心设计的防御性 div 替换成一个更"简洁"的 Fragment。

ESLint 拦不住这种事,因为 Fragment 在语法上是完全合法的。TypeScript 也拦不住,因为类型推导不受影响。唯一能拦住的是告诉 AI 不要这么做

这就是 CLAUDE.md 的作用。它是项目根目录下的规则文件,所有主流 AI 编程助手在接手项目时都会优先读取它:

## 发布文章 SEO 铁律

为了保证博客网站的 SEO 永远维持在"超越 Astra"级别,
**严禁手动复制粘贴创建新文章的 `page.tsx`**1. **新建文章必须使用脚手架**:
   运行 `pnpm post:new`,脚本会自动生成包含 Canonical、
   JSON-LD (BlogPosting)、OpenGraph 和 Twitter Card 的完美骨架。

2. **构建前的自动化拦截**:
   `pnpm build` 已集成 `pnpm post:verify` 卡点。
   任何缺少核心 SEO 配置的文章都会直接阻断构建。

这看起来像是一份普通的文档,但实际上它是一个针对 AI 的 Prompt 注入攻击——正向的。

你在告诉 AI:"在这个项目里,这些是不可触碰的铁律。" AI 助手读取这份文件后,会在生成代码时主动遵守这些规则。相当于给 AI 戴了一个紧箍咒。

三道防线的协同

单独看每一道防线都有绕过的可能:

防线 能拦截 绕过方式
脚手架 新建时的错误模板 手动创建文件
构建卡点 所有已知的违规模式 不走 pnpm build 直接改服务器
CLAUDE.md AI 的自作主张 AI 忽略规则文件

但当三者组合在一起时,绕过的路径被彻底堵死:

手动创建 page.tsx → 忘记加 min-h-screen 容器
    ↓
git commit → git push → CI/CD 触发 docker build
    ↓
docker build 执行 pnpm build → 先跑 verify-seo.js
    ↓
检测到外层是 <> → process.exit(1)
    ↓
❌ 构建崩溃,部署被阻断

即使有人想绕过构建直接改服务器,CLAUDE.md 里还有另一条铁律:

禁止使用 rsync 同步代码到服务器。 所有代码变更必须通过 git push → 服务器 git pull → docker compose build 流程部署。

rsync 绕路会被 Git 历史脱节、构建产物残留等一系列问题反噬。唯一正确的路径就是那条会被 verify-seo.js 审查的路径。

这就是防御纵深(Defense in Depth) 的思想:不依赖单一检查点,而是用多层独立机制互相兜底。每一层都可能失效,但所有层同时失效的概率趋近于零。

为什么这件事值得写

你可能觉得:"这不就是加了几个检查脚本吗?有什么好写的?"

但你换个角度想:

2026 年,AI 编程助手已经成为标准配置。 Cursor 估值冲到 billions 级别,Claude Code、GitHub Copilot Workspace 成了开发者的日常工具。但同时, "AI 悄悄吃掉关键代码" 已经成为团队里最高频的隐形故障来源。

市面上的讨论几乎全停留在理念层面:"我们要 Review AI 的输出"、"我们要建立 AI Code Review 流程"。说得好听,但没有落地的工程化解法。

而我这套方案,是可以用 git clone 跑起来的完整闭环。三个文件,零外部依赖,插入任何 Next.js 项目即可生效。不需要配置 ESLint 插件,不需要买 CI/CD 付费套餐,不需要说服团队改变工作流。

真正的架构师能力,不只是写出高性能的底层代码(那是单兵作战),而是能把从代码生成、到构建拦截、再到 AI 协同的整条流水线打造得绝对防弹(Bulletproof)

前者让你成为一个优秀的工程师,后者让你成为一个能交付靠谱系统的架构师。


开源仓库 → github.com/hlng2002/ne…

三条命令接入你的项目:

# 1. 复制脚本到你的项目
cp create-post.js your-project/scripts/create-post.js
cp verify-seo.js your-project/scripts/verify-seo.js

# 2. 添加 npm scripts
# "post:new": "node scripts/create-post.js",
# "post:verify": "node scripts/verify-seo.js",
# "build": "pnpm post:verify && pnpm --filter './apps/*' build"

# 3. 写入 CLAUDE.md 铁律(见上文)

打开 Live Lab → diffserv.xyz

🥚 彩蛋:就在写这篇文章时,AI 当着我的面绕过了第一道防线

你可能觉得"AI 绕过防御"是理论推演,但它就发生在我部署这篇文章的时候。

我的 AI 助手在生成 page.tsx 时,直接手写了整个文件,完全跳过了 pnpm post:new 脚手架。理由极其嚣张:"你的脚手架是交互式的,我直接写更快。"

是的,我设计的第一个防线,被我的 AI 助手当面绕过了。

但这就是第二道防线存在的意义——就算 AI 绕过了脚手架,构建时校验依然死死守着底线。如果它手写的文件漏了 canonicalgenerateBlogPostingJsonLdpnpm build 会直接报错,部署阻断。第一道防线被破,第二道防线兜底。

这不是理论,这是实战。 三重防御不是为了防一个完美的世界,是为了在 AI 叛逆的时候,还有底线活着。

2026-04-15 · 工程化免疫闭环 · diffserv.xyz

从零搭建 Monorepo 自动发布工作流(GitHub Actions + pnpm + Lerna)

作者 donecoding
2026年4月14日 23:09

🚀 省流助手 (速通结论)

如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面最后的 GitHub Actions 配置即可开箱即用:

三个核心要点

  1. 只监听 PR 合并事件,避免手动推送误触发。
  2. 发布前先将 master 同步到 release,确保基于最新主干代码发版。
  3. 发布后使用 --ff-only 快进 master,保持历史线性且零冲突。

如果你想知道为什么这么设计、如何避坑,请继续阅读全文。

1. 引言:为什么要折腾这套流程?

在 Monorepo 项目中,包的版本管理和发布往往是最繁琐的环节。手动执行 lerna publish 不仅容易忘记切换 Node 版本、打错标签,还可能在多人协作时出现版本冲突或漏发包的情况。

本文将手把手带你用 GitHub Actions 搭建一套完全自动化的发布流水线,实现以下效果:

  • ✅ 开发者只需将 PR 合并到 release 分支,剩下的全部交给机器人。
  • ✅ 自动计算版本号,自动生成 CHANGELOG,自动推送 Git 标签。
  • ✅ 发布完成后自动将 master 分支同步到最新状态,保持双分支一致。

2. 触发时机:如何精确捕获“PR 合并”事件?

很多同学一开始会写成这样:

on:
  push:
    branches:
      - release

问题:任何向 release 分支的推送都会触发(包括手动 git pushgit commit),不符合“只有 PR 合并才发布”的规范。

正确姿势是监听 pull_request 事件的 closed 类型:

on:
  pull_request:
    types:
      - closed
    branches:
      - release

closed 事件包含两种情形:合并后关闭直接关闭(未合并)。因此我们还需要在 Job 级别加一个条件过滤:

jobs:
  publish:
    if: github.event.pull_request.merged == true

这样就能精准命中“PR 已合并”的场景,完美避开直接关闭的空跑。

3. 环境配置:锁定 Node 与 pnpm 版本

为了避免因环境差异导致的构建失败,强烈建议将 Node.js 和 pnpm 的版本写死在环境变量中:

env:
  NODE_VERSION: "22"
  PNPM_VERSION: "10.33.0"

后续步骤通过 ${{ env.NODE_VERSION }}${{ env.PNPM_VERSION }} 引用,日后升级只需改一处即可。

- uses: pnpm/action-setup@v4
  with:
    version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
  with:
    node-version: ${{ env.NODE_VERSION }}
    registry-url: "https://registry.npmjs.org"

4. Git 身份配置:为什么必须用 [bot] 邮箱?

在 CI 中生成的提交需要有一个明确的作者身份。如果随意填写 ci@localhost,GitHub 会将其显示为灰色头像的“幽灵提交”,无法关联到任何账户,也不利于审计追溯。

正确做法是使用 GitHub Actions 官方的 Bot 身份:

- name: Configure Git
  run: |
    git config --global user.name "github-actions[bot]"
    git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

其中 41898282 是 GitHub Actions App 的唯一数字 ID,加上这串数字后提交会明确归属给机器人。

5. 分支同步策略:为什么发布前要合并 master

很多团队允许紧急 Hotfix 直接合并到 master 上线。如果 release 分支长期未更新,就可能基于过时代码发布,导致线上问题复现。

因此我们在发布前增加一步:

- name: Sync master into release
  run: |
    git fetch origin master
    git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"
  • --no-ff 保留合并历史,清晰记录本次同步动作。
  • 提交信息中带上 [skip ci] 是一个防御性习惯:即使未来因某种原因推送了这个合并提交,也不会触发额外的工作流。

6. Lerna 发布:本地生成提交,不着急推送

核心发布命令如下:

- name: Publish packages
  run: |
    npx lerna publish --yes \
      --conventional-graduate \
      --no-push \
      --message "chore(release): publish [skip ci]"
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
    GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}

参数解释:

  • --yes:跳过所有交互式确认,全自动执行。
  • --conventional-graduate:自动将当前为 alpha/beta 的预发布包“毕业”为正式版本(例如 1.0.0-alpha.01.0.0)。
  • --no-push禁止 Lerna 自动推送,改为后续手动推送。这样可以在 npm 发布成功后再推送 Git 标签,保证原子性。
  • --message:自定义提交信息,包含 [skip ci] 防止推送后再次触发本工作流。

7. 推送与主干快进:如何让 master 历史保持一条直线?

发布完成后,我们分两步推送:

第一步:推送 release 分支及标签

- name: Push release and tags
  run: git push --follow-tags origin release

第二步:将 master 快进到 release

- name: Fast-forward master
  run: |
    git fetch origin master
    git checkout master
    git merge --ff-only origin/release
    git push origin master

由于发布前我们已经将 master 合并到了 release,加上发布提交,release 必然比 master 多一个新提交。此时使用 --ff-only(仅快进)可以将 master 指针直接移动到 release 的位置,不会产生额外的合并提交,历史图谱干净如线。

8. 并发控制与安全兜底

concurrency:
  group: release-publish
  cancel-in-progress: false

这一配置确保同一时刻只有一个发布任务运行,新触发的任务会排队等待,避免多人同时合并 PR 造成 Git 推送冲突。

同时,工作流顶部声明权限:

permissions:
  contents: write

配合 Personal Access Token(需具备 Contents 读写权限),保证 Git 推送操作万无一失。

9. 结语

通过以上配置,我们实现了一套高内聚、低心智负担的 Monorepo 自动发布流水线。开发者只需专注于代码本身,合并 PR 后喝杯咖啡,机器人会自动完成剩下的所有脏活累活。

完整配置文件,欢迎直接复制使用。

如果你正在使用 pnpm + Lerna 管理 Monorepo,并且希望 PR 合并到 release 分支时自动发布 npm 包并同步 master 分支,直接复制下面这份 GitHub Actions 配置即可开箱即用:

name: Publish from Release
env:
  NODE_VERSION: "22"
  PNPM_VERSION: "10.33.0"

on:
  pull_request:
    types: [closed]
    branches: [release]

concurrency:
  group: release-publish
  cancel-in-progress: false

jobs:
  publish:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.RELEASE_GITHUB_TOKEN }}

      - uses: pnpm/action-setup@v4
        with:
          version: ${{ env.PNPM_VERSION }}

      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          registry-url: "https://registry.npmjs.org"

      - name: Configure Git
        run: |
          git config --global user.name "github-actions[bot]"
          git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"

      - name: Sync master into release
        run: |
          git fetch origin master
          git merge origin/master --no-ff -m "chore: sync master into release [skip ci]"

      - name: Install dependencies
        run: pnpm install

      - name: Publish packages
        run: |
          npx lerna publish --yes --conventional-graduate --no-push --message "chore(release): publish [skip ci]"
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
          GH_TOKEN: ${{ secrets.RELEASE_GITHUB_TOKEN }}

      - name: Push release and tags
        run: git push --follow-tags origin release

      - name: Fast-forward master
        run: |
          git fetch origin master
          git checkout master
          git merge --ff-only origin/release
          git push origin master

下一篇我们将深入探讨 Lerna 版本计算的底层逻辑,以及如何解决令人头疼的 bad revision 'undefined' 错误——敬请期待。

❌
❌