普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月14日首页

CSS mask 完全指南:从渐变裁切到弹幕遮挡

作者 bytemanx
2026年4月14日 17:41

CSS 属性里,mask 大概是被低估最严重的那一个。很多人知道它能"遮住一些东西",但真正上手时又觉得无从下手。其实 mask 的语法和 background 几乎一模一样——如果你已经玩转了渐变背景,那 mask 对你来说就是换个属性名的事。

本文会从语法开始,一路讲到弹幕遮挡、转场动画这些实战场景。每个案例都附带可运行的代码。


1. mask 到底是什么?

一句话:mask 决定元素的哪些部分可见、哪些部分透明

它接受的值和 background 一样——渐变、图片、SVG 都行。工作原理也简单:

  • mask 中有颜色的区域(不管什么颜色),对应元素内容可见
  • mask 中透明的区域,对应元素内容不可见

来看最基础的例子:

.demo {
  background: url(photo.jpg);
  -webkit-mask: linear-gradient(90deg, transparent, #000);
  mask: linear-gradient(90deg, transparent, #000);
}

效果是图片从左侧完全透明,到右侧完全可见——一个从无到有的渐隐效果。

这里 #000 换成 redblue 或任何颜色,效果完全一样。mask 只关心透明度,不关心色相。


2. mask 语法详解

根据 MDN CSS mask 文档:

The mask shorthand CSS property hides an element (partially or fully) by masking or clipping the image at specific points. It is a shorthand for mask-image, mask-mode, mask-repeat, mask-position, mask-clip, mask-origin, mask-size, and mask-composite.

mask 是一个简写属性,包含以下子属性:

子属性 作用 对应的 background 属性
mask-image 遮罩图像(渐变/图片/SVG) background-image
mask-size 遮罩尺寸 background-size
mask-repeat 是否平铺 background-repeat
mask-position 遮罩定位 background-position
mask-origin 定位参考框 background-origin
mask-clip 裁切参考框 background-clip
mask-composite 多个遮罩的合成方式 无对应属性

看到没有?除了 mask-composite,其他属性和 background 完全对应。如果你已经熟悉了 background-sizebackground-position 这些属性,mask 的学习成本几乎为零。

兼容性前缀

目前(2026 年)在 Chrome、Edge 等 Blink 内核浏览器中,mask 仍需 -webkit- 前缀。实际写代码时建议这样写:

.el {
  -webkit-mask: linear-gradient(#000, transparent);
  mask: linear-gradient(#000, transparent);
}

或者直接在构建工具中配置 autoprefixer,让它帮你加前缀。


3. 基础用法:渐变遮罩裁切

3.1 案例:图片切角效果

多层线性渐变可以拼出切角图形,这个技巧在 background 上就能用。把同样的渐变写到 mask 里,就能把任意元素裁成切角造型——不管元素里面是图片、文字还是渐变背景。

.notch-image {
  width: 300px;
  height: 200px;
  background: url(https://picsum.photos/300/200) no-repeat center/cover;
  -webkit-mask:
    linear-gradient(135deg, transparent 15px, #fff 0) top left,
    linear-gradient(-135deg, transparent 15px, #fff 0) top right,
    linear-gradient(-45deg, transparent 15px, #fff 0) bottom right,
    linear-gradient(45deg, transparent 15px, #fff 0) bottom left;
  -webkit-mask-size: 50% 50%;
  -webkit-mask-repeat: no-repeat;
  mask:
    linear-gradient(135deg, transparent 15px, #fff 0) top left,
    linear-gradient(-135deg, transparent 15px, #fff 0) top right,
    linear-gradient(-45deg, transparent 15px, #fff 0) bottom right,
    linear-gradient(45deg, transparent 15px, #fff 0) bottom left;
  mask-size: 50% 50%;
  mask-repeat: no-repeat;
}

四个方向的渐变各占 50% 50%,拼在一起刚好覆盖整个元素。每个渐变在角落处用 transparent 挖掉一个三角形,组合起来就是四角切角。

这里的 #fff 0 用了渐变简写技巧:0 会被浏览器修正为前一个色标的位置 15px,形成硬边界。


3.2 案例:内切圆角按钮

普通的内切圆角用 radial-gradient 就能画出来。但问题在于:如果按钮背景是渐变色而不是纯色,直接用 background 画内切圆角基本无解——你没法让两层渐变"叠加"出一个圆角效果。

mask 能解决这个问题:把内切圆角的形状写成 mask,background 想用什么渐变都行

.inset-btn {
  padding: 16px 48px;
  font-size: 16px;
  color: #fff;
  border: none;
  background: linear-gradient(45deg, #2179f5, #e91e63);
  -webkit-mask:
    radial-gradient(
        circle at 100% 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right bottom,
    radial-gradient(circle at 0 0, transparent 0, transparent 12px, #fff 13px)
      left top,
    radial-gradient(
        circle at 100% 0,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right top,
    radial-gradient(
        circle at 0 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      left bottom;
  -webkit-mask-size: 50% 50%;
  -webkit-mask-repeat: no-repeat;
  mask:
    radial-gradient(
        circle at 100% 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right bottom,
    radial-gradient(circle at 0 0, transparent 0, transparent 12px, #fff 13px)
      left top,
    radial-gradient(
        circle at 100% 0,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      right top,
    radial-gradient(
        circle at 0 100%,
        transparent 0,
        transparent 12px,
        #fff 13px
      )
      left bottom;
  mask-size: 50% 50%;
  mask-repeat: no-repeat;
}

原理:四个 radial-gradient 分别处理四个角,每个径向渐变的圆心在对应角落,0~12px 的范围是透明的(挖出圆弧),13px 往外是白色(保留内容)。

改变 12px 的值可以调整圆弧大小。这种方案的好处是 background 完全自由——纯色、渐变、图片都没问题。


4. 进阶用法:渐变消失与融合

4.1 案例:横向滚动列表的渐变消失

在很多产品里都能看到这种效果:一个横向可滚动的列表,右侧内容渐渐消失,暗示用户"还有更多内容"。

不用 mask 的话你可能会想到覆盖一个半透明遮罩层。但这有个麻烦:遮罩层会挡住点击事件,还需要设置 pointer-events: none

用 mask 就一行代码:

.scroll-list {
  display: flex;
  overflow-x: auto;
  gap: 12px;
  -webkit-mask: linear-gradient(90deg, #000 70%, transparent);
  mask: linear-gradient(90deg, #000 70%, transparent);
}

linear-gradient(90deg, #000 70%, transparent) 的意思是:从左到右,前 70% 完全可见,剩下 30% 逐渐透明。就这么简单。

要注意一点:mask 作用于整个元素及其内容,包括文字、子元素、甚至滚动条。这正是 mask 和 "覆盖一层遮罩" 的本质区别——mask 是从元素自身出发做裁切,而不是在上面盖东西。


4.2 案例:两张图片融合

mask 做图片融合非常直观:两张图片叠在一起,上层图片加一个 mask,mask 的透明区域会露出下层图片。

.blend {
  position: relative;
  width: 400px;
  height: 300px;
  background: url(https://picsum.photos/400/300?random=1) no-repeat center/cover;
}

.blend::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/300?random=2) no-repeat center/cover;
  -webkit-mask: linear-gradient(45deg, #000 40%, transparent 60%);
  mask: linear-gradient(45deg, #000 40%, transparent 60%);
}

linear-gradient(45deg, #000 40%, transparent 60%) 中,40% 到 60% 这段是过渡区——两张图片在这里平滑融合。如果你把它改成 #000 50%, transparent 50%,那就是硬切割,没有过渡。

除了 linear-gradient 做线性方向的融合,radial-gradient 可以做径向区域的融合——在画面中某个位置开一个"窗口",露出下层的内容:

.radial-blend {
  position: relative;
  width: 520px;
  height: 320px;
  overflow: hidden;
}

.radial-blend .layer-cold {
  position: absolute;
  inset: 0;
  background: url(scene-cold.jpg) center / cover no-repeat;
}

.radial-blend .layer-warm {
  position: absolute;
  inset: 0;
  background: url(scene-warm.jpg) center / cover no-repeat;
  -webkit-mask: radial-gradient(circle at 35% 45%, #000 20%, transparent 60%);
  mask: radial-gradient(circle at 35% 45%, #000 20%, transparent 60%);
}

上层暖色调图片通过 radial-gradient 只在左侧偏上的位置可见,向外逐渐透明,露出底层冷色调图片。两张风格不同的照片在圆形过渡区自然融合。


5. mask-composite:组合遮罩

当一个元素有多个 mask 时,mask-composite 决定它们之间怎么合成。

根据 MDN mask-composite 文档:

The mask-composite CSS property represents a compositing operation used on the current mask layer with the mask layers below it.

标准语法支持四个关键字:

mask-composite: add; /* 叠加(默认)*/
mask-composite: subtract; /* 减去 */
mask-composite: intersect; /* 取交集 */
mask-composite: exclude; /* 排除重叠 */

但 WebKit 浏览器用的是另一套语法(-webkit-mask-composite),常用的值有:

-webkit-mask-composite: source-over; /* 对应 add */
-webkit-mask-composite: source-in; /* 对应 intersect */
-webkit-mask-composite: source-out; /* 只显示上层独有部分 */
-webkit-mask-composite: destination-out; /* 只显示下层独有部分 */
-webkit-mask-composite: xor; /* 对应 exclude */

案例:两个圆弧取交集

假设你想裁出一个"两个圆弧重叠"的形状:

.composite-demo {
  width: 300px;
  height: 200px;
  background: linear-gradient(135deg, #667eea, #764ba2);
  -webkit-mask:
    radial-gradient(circle at 100% 0, #000, #000 200px, transparent 200px),
    radial-gradient(circle at 0 0, #000, #000 200px, transparent 200px);
  -webkit-mask-composite: source-in;
  mask:
    radial-gradient(circle at 100% 0, #000, #000 200px, transparent 200px),
    radial-gradient(circle at 0 0, #000, #000 200px, transparent 200px);
  mask-composite: intersect;
}

如果不加 mask-composite,两个 mask 默认是 add(叠加),你看到的是两个圆弧的并集。加上 intersect(或 -webkit-mask-composite: source-in),就只保留两个圆弧重叠的部分

这个能力在做异形裁切时很有用:单个渐变很难画出的形状,可以通过多个简单渐变组合得到。


6. 高阶动画:mask 驱动的转场

mask 不只是静态裁切。通过动态改变 mask 的值,可以实现各种转场和切换效果。

6.1 渐变不能直接做动画——怎么办?

CSS 渐变本身不支持 transitionanimation。也就是说你写 transition: mask 0.3s 是没用的,linear-gradient 内部的参数变化不会有平滑过渡。

两种绕过方案:

  1. 逐帧动画:用 SASS 循环生成 0% 到 100% 共 101 帧的 @keyframes,每一帧写死 mask 的值
  2. CSS @property:注册一个自定义属性,让浏览器知道这个变量是 <percentage> 类型,这样它就能被动画插值

第一种方案的代码经过 SASS 编译后非常臃肿(101 帧)。推荐用第二种。

6.2 案例:conic-gradient 扇形转场(CSS @property 方案)

这是一个经典的转场效果:上层图片像扇形展开一样逐渐覆盖下层图片。hover 时触发动画。

@property --conic-p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: -10%;
}

.transition-box {
  position: relative;
  width: 400px;
  height: 400px;
  background: url(https://picsum.photos/400/400?random=5) no-repeat center/cover;
  cursor: pointer;
}

.transition-box::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/400?random=100) no-repeat
    center/cover;
  pointer-events: none;
  -webkit-mask: conic-gradient(
    #000 0,
    #000 var(--conic-p),
    transparent calc(var(--conic-p) + 10%),
    transparent
  );
  mask: conic-gradient(
    #000 0,
    #000 var(--conic-p),
    transparent calc(var(--conic-p) + 10%),
    transparent
  );
}

.transition-box:hover::before {
  animation: conicSweep 1.5s ease-in-out forwards;
}

@keyframes conicSweep {
  from {
    --conic-p: -10%;
  }
  to {
    --conic-p: 100%;
  }
}

这里有几个关键点:

  • @property --conic-p:注册之后,浏览器知道 --conic-p 是百分比类型,可以在动画中平滑插值。mask 里的 conic-gradient 会随着 --conic-p 从 -10% 变化到 100%,像时钟指针一样扫过整个圆。
  • pointer-events: none:伪元素覆盖在容器上层,如果不加这个属性,鼠标事件会被伪元素拦截,导致容器的 :hover 状态无法触发。
  • calc(var(--conic-p) + 10%) 多出的 10% 是过渡区,让边缘不那么生硬。如果你想要硬边界,把 +10% 去掉就行。

同样的思路,换成 linear-gradient 就是一个从左到右的滑动转场:

@property --slide-p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 0%;
}

.slide-box {
  position: relative;
  width: 400px;
  height: 400px;
  background: url(https://picsum.photos/400/400?random=10) no-repeat
    center/cover;
  cursor: pointer;
}

.slide-box::before {
  content: '';
  position: absolute;
  inset: 0;
  background: url(https://picsum.photos/400/400?random=200) no-repeat
    center/cover;
  pointer-events: none;
  -webkit-mask: linear-gradient(
    90deg,
    #000 var(--slide-p),
    transparent calc(var(--slide-p) + 5%)
  );
  mask: linear-gradient(
    90deg,
    #000 var(--slide-p),
    transparent calc(var(--slide-p) + 5%)
  );
}

.slide-box:hover::before {
  animation: slideReveal 1.2s ease-in-out forwards;
}

@keyframes slideReveal {
  from {
    --slide-p: 0%;
  }
  to {
    --slide-p: 100%;
  }
}

和扇形转场的原理完全一样,只是把 conic-gradient 换成了 linear-gradient--slide-p 从 0% 变化到 100%,实色区域从左往右推进,形成滑动揭示的效果。

如果你的目标浏览器不支持 @property(比如旧版 Firefox),也可以用 SASS 逐帧方案替代:

@keyframes maskSlide {
  @for $i from 0 through 100 {
    #{$i}% {
      mask: linear-gradient(
        90deg,
        #000 #{$i + '%'},
        transparent #{$i + 5 + '%'}
      );
    }
  }
}

编译后会生成 101 帧的 @keyframes,每一帧写死 mask 的值,代码量大但兼容性最好。


7. 实战:弹幕人物遮挡效果

在 BiliBili 或虎牙直播中,弹幕经过人物区域时会自动"绕道"——弹幕看起来在人物的后面。这个效果的实现原理就是 mask。

原理

  1. 视频画面和弹幕容器是两层叠加结构,弹幕在上层
  2. 后端通过图像识别算法,实时计算出人物的轮廓区域
  3. 生成一张 SVG/PNG 图片:人物轮廓区域是透明的,其他区域是白色/实色的
  4. 把这张图片设为弹幕容器的 mask-image
  5. 根据 mask 的工作原理——透明区域对应的弹幕内容不可见——弹幕就"消失"在人物背后了
  6. 随着视频播放,后端不断更新 mask 图片,实现实时遮挡

简化模拟

后端的实时图像识别我们没法在前端模拟,但原理可以用 radial-gradient 来演示:

.barrage-container {
  position: absolute;
  inset: 0;
  -webkit-mask: radial-gradient(
    circle at 100px 100px,
    transparent 60px,
    #fff 80px,
    #fff 100%
  );
  mask: radial-gradient(
    circle at 100px 100px,
    transparent 60px,
    #fff 80px,
    #fff 100%
  );
  animation: maskFollow 6s infinite alternate linear;
}

@keyframes maskFollow {
  to {
    -webkit-mask-position: 80vw 0;
    mask-position: 80vw 0;
  }
}

radial-gradient(100px, 100px) 位置挖了一个半径 60px 的圆形透明区域,60px 到 80px 是过渡,80px 以外完全可见。通过动画移动 mask-position,这个"挖洞"就会跟着移动。

真实场景中,这个 "挖洞" 的形状不是简单的圆形,而是从后端返回的人物轮廓 SVG。但 mask 的使用方式完全相同。

要搞清楚一点:mask 遮挡的是弹幕容器,不是人物。mask 的透明区域让弹幕不可见,从而"露出"弹幕下方的人物画面。


9. 兼容性

mask 属性的浏览器支持已经相当好了:

浏览器 支持情况
Chrome / Edge 支持(需 -webkit- 前缀)
Firefox 完全支持(无需前缀)
Safari 支持(需 -webkit- 前缀)
IE 不支持

如果你不需要兼容 IE,mask 可以放心用。前缀问题交给 autoprefixer 处理:

// postcss.config.js
module.exports = {
  plugins: [require('autoprefixer')],
};

mask-composite 的兼容性稍差一些,使用前建议在 Can I Use 上确认目标浏览器的支持情况。


10. 总结

核心原则

mask 中有颜色 → 内容可见透明 → 内容不可见。记住这一条就够了。

技巧速查表

技巧 实现方式 典型场景
渐变遮罩 mask: linear-gradient(...) 内容淡出、列表渐隐
切角/异形裁切 多重 linear-gradient + mask-size: 50% 50% 图片切角、优惠券造型
内切圆角 多重 radial-gradient 不规则按钮、卡片
图片融合 伪元素叠加 + mask 两图过渡、径向区域融合
组合遮罩 mask-composite: intersect 多 mask 取交集/差集
渐变动画转场 @property + conic-gradient 扇形展开、滑动切换
图表重绘 @property + conic-gradient + :hover 数据可视化 hover 效果
弹幕遮挡 radial-gradient / 实时图片 视频直播弹幕
雪碧图转场 mask: url(sprite.png) + steps() 精致页面转场

和 background 的关系

mask 的语法和 background 几乎一一对应——多层叠加、repeat、position、size 这些在 background 上能做的事,mask 上全能做。多出来的 mask-composite 让多个 mask 之间的布尔运算成为可能,这是 background 没有的能力。


延伸阅读

Flutter应用代码混淆完整指南:Android与iOS平台配置详解

2026年4月14日 14:37

Flutter中的代码混淆

代码混淆可以隐藏你的Dart代码中的函数和类名,让 反编译 App变得困难。对于更全面的混淆需求,特别是针对iOS IPA文件,可以使用专业工具如IpaGuard,它支持无需源码的代码和资源混淆,兼容Flutter等多种开发平台,有效增加反编译难度。

注:Dart的混淆还没有经过完全的测试,如果发现问题请到GitHub上提 issue 。关于混淆的问题,还可以参考 Stack Overflow 上的这个问题。

Flutter中的混淆配置其实是在Android和iOS端分别配置的。

Android

<ProjectRoot>/android/gradle.properties 文件中添加如下代码:

extra-gen-snapshot-options=--obfuscate

默认情况下,Flutter不会混淆或者缩减Android host,如果你使用了第三方的Java或者Android库,那么你可能需要减小APK体积,或者防止你的App被反编译。

  • Step 1:配置Proguard文件

新建 /android/app/proguard-rules.pro 文件,然后添加如下配置:


#Flutter Wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.**  { *; }
-keep class io.flutter.util.**  { *; }
-keep class io.flutter.view.**  { *; }
-keep class io.flutter.**  { *; }
-keep class io.flutter.plugins.**  { *; }

上面的配置只保护Flutter库,其他额外的库(比如Firebase)需要你自己添加配置。

  • Step 2:

打开 /android/app/build.gradle 文件,定位到 buildTypes 处,在 release 配置中将 minifiyEnableduseProguard 标志设为true,同时还需要指向Step1中创建的ProGuard文件:


android {
    ...
    buildTypes {
        release {
            signingConfig signingConfigs.debug
            minifyEnabled true
            useProguard true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

注意混淆和缩减无用代码会加长App的编译时间。

iOS

  • Step 1:修改 "build aot"

<ProjectRoot>/packages/flutter_tools/bin/xcode_backend.sh 文件中添加 build aot flag:

${extra_gen_snapshot_options_or_none}

然后定义这个flag:


local extra_gen_snapshot_options_or_none=""
if [[ -n "$EXTRA_GEN_SNAPSHOT_OPTIONS" ]]; then
  extra_gen_snapshot_options_or_none="--extra-gen-snapshot-options=$EXTRA_GEN_SNAPSHOT_OPTIONS"
fi
  • Step 2:应用你的修改

在你的App的根目录下运行以下两条命令:


git commit -am "Enable obfuscation on iOS"
flutter
  • Step 3:更改release配置

<ProjectRoot>/ios/Flutter/Release.xcconfig 中添加下面这行:

EXTRA_GEN_SNAPSHOT_OPTIONS=--obfuscate

对于iOS平台,如果需要更强大的混淆保护,可以考虑使用IpaGuard这样的工具,它可以直接对IPA文件进行混淆加密,支持代码和资源文件的全面混淆,无需源码即可操作,并兼容Flutter应用,提供即时测试功能。

nestjs实战-登录、鉴权(二)

作者 web_bee
2026年4月14日 10:48

nestjs实战-登录、鉴权(二)

上一章中介绍了登录鉴权分两步:

  • 用户登录过程
  • 登录成功后,带token请求业务接口的过程

用户登录过程已经介绍,接下来介绍一下业务流程中的认证过程

一、业务接口token验证过程

业务接口验证流程:

  • 登录成功后,用户将token存储到本地缓存,每次发送请求时,需要在header,Authentication:[token] 将token带给后端

  • 后端操作:

    • 全局守卫 jwt.guard.ts ,是否进入jwt策略、白名单校验等
    • 触发 JWT 策略:获取jwt、解析、验证等操作
    • 成功后进入 控制器、服务、最终返回数据

二、先看代码实现:

先看一下目录结构:

auth-dir.png

首先需要创建两个文件 jwt.guard.tsjwt.strategy.ts,后面再介绍文件内的实现;

我们希望所有业务代码都需要进行token验证(提供不走验证逻辑的配置),所以 jwt.guard.ts守卫需要全局注册:

App.module.ts

import { APP_GUARD } from '@nestjs/core';
import { JwtGuard } from './modules/auth/guards/jwt.guard';

@Module({
  // ...
  providers: [
    // 全局注册 jwt 守卫,所有业务接口都会走这个守卫
    { provide: APP_GUARD, useClass: JwtGuard },
  ],
})
export class AppModule {}

auth.module.ts

import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  // ...
  providers: [
    // ...
    JwtStrategy,
  ],
})

在哪里使用 JwtStrategy 呢?

在代码中只看到 jwt.strategy.ts 作为一个提供者,在 auth.module.ts 中被注册;

提供者正常分三步:定义、注册、[注入|使用](类的构造函数constructor中注入),但是 jwt.strategy.ts 只有定义、注册 两部,因为:

之所以在代码里找不到 JwtStrategyconstructor 注入的地方,是因为它的工作方式比较特殊:它是被 NestJS 框架“自动”消费的,而不是被你的业务代码显式注入的

逻辑梳理

我们的登录、注册接口肯定是不需要进行 token 验证的,所以我们这个 jwt 全局守卫还需要一个开关来控制;

还记得我们在之前的章节介绍 拦截器-统一响应数据格式,也有类似的开关控制bypass.decorator.ts 内部逻辑,为类或方法 设置元数据 SetMetadata

此处也是类似的逻辑:

public.decorator.ts

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

auth.controller.ts

为类 设置上 特定的元数据,后续在 守卫中获取对应的值,作为判断逻辑;

// ...
import { Public } from '~/common/decorators/public.decorator';

@Controller('auth')
@Public() // 类下面所有的路由都不需要检验token
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly usersService: UsersService,
  ) {}

  // 注册
  @Post()
  register(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  // 登录
  @Post('login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
}

核心逻辑

Jwt.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';

import { Reflector } from '@nestjs/core';

import { AuthStrategy } from '../auth.constant';
import { IS_PUBLIC_KEY } from '~/common/decorators/public.decorator';

@Injectable()
export class JwtGuard extends AuthGuard(AuthStrategy.JWT) {

  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    // 获取 类、方法上的 元数据
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    
    // 不需要校验token,接口可以直接访问,例如:登录、注册、获取验证码 等接口
    if (isPublic) {
      return true;
    }

    /**
     * super.canActivate(context) 的作用是:调用 @nestjs/passport 里已经实现好的 JWT 认证流程,包括:
     * 1. 从请求中提取 token(通常是 Bearer)
     * 2. 调用对应 JwtStrategy
     * 3. 验证签名、过期时间等
     * 4. 验证通过后把结果挂到 request.user
     * 5. 最后返回 true(通过)或抛异常(401)
     */
    return super.canActivate(context);
  }
}
Jwt.strategy.ts

在守卫触发后,通过守卫内部的 super.canActivate(context) 触发执行此策略,注意策略名称需一致;

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthStrategy } from '../auth.constant';
import { securityRegToken, ISecurityConfig } from '~/config';
import { ConfigService } from '@nestjs/config';


@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, AuthStrategy.JWT) {
  constructor(
    private readonly configService: ConfigService,
  ) {
    const securityConfig = configService.get<ISecurityConfig>(securityRegToken)

    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: securityConfig.jwtSecret,
    });
  }

  validate(payload: any) {
    console.log('payload', payload);
    return payload;
  }
}

代码部分说明:

  • PassportStrategy:Nest 里把 passport-jwtStrategy 包装成可注入的类。
  • ExtractJwtStrategy:来自 passport-jwt,负责「从请求里拿 JWT」和「验签逻辑」。

  •   export class JwtStrategy extends PassportStrategy(Strategy, AuthStrategy.JWT)
    
    • 第二个参数 AuthStrategy.JWT 是 策略名(一般是 'jwt'),要和 AuthGuard('jwt') / AuthGuard(AuthStrategy.JWT) 一致。
    • Nest 会在需要 JWT 认证时,走这个策略。
  • 构造函数里的 super({...})

    • jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken() 只从 Authorization: Bearer <token> 里取 JWT;没有 Bearer 或格式不对,认证会失败。
    • ignoreExpiration: false 过期 token 会被拒绝;若为 true 则仍可能验签通过(一般不这么配在生产鉴权上)。
    • secretOrKey: securityConfig.jwtSecret 和签发 token 时用的密钥必须一致,否则验签失败。
  •   validate(payload)
    
    • JWT 验签、过期检查通过后,passport-jwt 会把解码后的 payload(一般是 { sub, name, ... })传给 validate
    • 你这里 return payload,表示 request.user 就是整个 payload。
    • 若你希望 req.user 是数据库里的用户对象,通常会在这里根据 payload.sub 查库,再 return user

和请求流程的关系

Guard 触发 JWT 策略 → 从 Header 取 token → 用 jwtSecret 验签 → 调用 validate(payload) → 返回值赋给 request.user → 再进控制器。

三、整个过程的生命周期

当我访问一个业务接口时的执行顺序,例如直接访问 /users 接口:

  1. 全局 Guard 先执行:JwtGuard.canActivate()

  2. JwtGuard 内部调用 super.canActivate(context)AuthGuard('jwt')

  3. 触发 JWT Strategy:JwtStrategy

    • Authorization: Bearer xxx 提取 token
    • 验签、校验过期
    • 调用 JwtStrategy.validate(payload),并把返回值挂到 request.user
    • 以上操作都是库自动帮我们执行的
  4. Guard 通过后,进入 Controller、Service ,最终响应给前端

四、总结

以上就完成熟悉了整个 登录鉴权的过程;

  • 熟悉了守卫的实战场景
  • 熟悉 鉴权相关的逻辑流程,不管是nestjs 还是其他后端语言,这块逻辑是不变的
  • 基于nestjs,熟悉了它的实现流程,各个npm包的作用

Python高级特性:Map和Reduce函数完全指南

作者 MgArcher
2026年4月13日 18:32

以下是根据您提供的链接内容,完整改写为CSDN博客风格的文章,并按照您的要求,在前言后面添加了原文链接,在尾部添加了推广内容。


Python高级特性:Map和Reduce函数完全指南

函数式编程的数据处理利器

前言

map()reduce()是Python中两个重要的高阶函数,它们源自函数式编程范式,与Google著名的MapReduce分布式计算模型有着相似的思想。掌握这两个函数,可以让你以更声明式、更简洁的方式处理数据集合。

本文将系统讲解map()reduce()的语法、工作原理、实际应用案例以及与现代Python特性的对比,帮助你提升数据处理能力。

📚 本文内容基于道满PythonAI - Map和Reduce函数教程


一、Map函数

map()函数将一个函数应用于一个可迭代对象(iterable)的每个元素,并返回一个迭代器(iterator)。

1.1 基本语法

map(function, iterable, ...)
参数 说明
function 应用到每个元素的函数
iterable 一个或多个可迭代对象
返回值 迭代器(惰性求值)

1.2 基本示例

# 定义平方函数
def square(x):
    return x * x

# 应用map
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]
squared = map(square, numbers)

# map返回迭代器,需要转换为列表查看结果
print(squared)           # <map object at 0x...>
print(list(squared))     # [1, 4, 9, 16, 25, 36, 49, 64, 81]

# 注意:迭代器只能使用一次
print(list(squared))     # [] - 已经耗尽!

1.3 使用lambda表达式

# 使用lambda简化代码
squared = map(lambda x: x**2, [1, 2, 3, 4, 5])
print(list(squared))  # [1, 4, 9, 16, 25]

# 求立方
cubed = map(lambda x: x**3, [1, 2, 3, 4, 5])
print(list(cubed))    # [1, 8, 27, 64, 125]

# 转字符串
str_nums = map(str, [1, 2, 3, 4, 5])
print(list(str_nums)) # ['1', '2', '3', '4', '5']

1.4 多参数map

map()可以接收多个可迭代对象,函数应接受相应数量的参数:

# 两个列表对应元素相加
list1 = [1, 2, 3]
list2 = [4, 5, 6]
result = map(lambda x, y: x + y, list1, list2)
print(list(result))  # [5, 7, 9]

# 三个列表对应元素相乘
a = [1, 2, 3]
b = [4, 5, 6]
c = [7, 8, 9]
product = map(lambda x, y, z: x * y * z, a, b, c)
print(list(product))  # [28, 80, 162]

# 不同长度的列表:以最短的为准
list1 = [1, 2, 3, 4]
list2 = [10, 20]
result = map(lambda x, y: x + y, list1, list2)
print(list(result))  # [11, 22] - 只处理到最短列表结束

二、Reduce函数

reduce()函数对一个序列中的元素进行累积计算。从Python 3开始,reduce()被移到了functools模块。

2.1 基本语法

from functools import reduce

reduce(function, sequence[, initial])
参数 说明
function 接收两个参数的累积函数
sequence 可迭代序列
initial 可选,初始值
返回值 单个累积结果

2.2 工作原理

reduce(f, [x1, x2, x3, x4])的计算过程等价于:

f(f(f(x1, x2), x3), x4)

2.3 基本示例

from functools import reduce

# 累加求和
def add(x, y):
    return x + y

total = reduce(add, [1, 3, 5, 7, 9])
print(total)  # 25 (1+3+5+7+9)

# 使用lambda简化
total = reduce(lambda x, y: x + y, [1, 3, 5, 7, 9])
print(total)  # 25

# 求最大值
numbers = [5, 2, 8, 1, 9, 3]
max_num = reduce(lambda x, y: x if x > y else y, numbers)
print(max_num)  # 9

# 使用初始值
total = reduce(lambda x, y: x + y, [1, 2, 3], 10)
print(total)  # 16 (10+1+2+3)

# 空序列必须提供初始值
total = reduce(lambda x, y: x + y, [], 0)
print(total)  # 0

2.4 更复杂的例子:数字列表转整数

from functools import reduce

def digits_to_num(digits):
    """将数字列表转换为整数"""
    return reduce(lambda x, y: x * 10 + y, digits)

print(digits_to_num([1, 3, 5, 7, 9]))  # 13579
print(digits_to_num([0, 1, 2, 3]))     # 123

三、实用案例

3.1 字符串规范化(使用map)

def normalize(name):
    """将名字规范化为首字母大写,其余小写"""
    return name.capitalize()

names = ['adam', 'LISA', 'barT']
normalized_names = list(map(normalize, names))
print(normalized_names)  # ['Adam', 'Lisa', 'Bart']

# 使用lambda一行搞定
normalized = list(map(lambda s: s.capitalize(), ['adam', 'LISA', 'barT']))
print(normalized)  # ['Adam', 'Lisa', 'Bart']

3.2 列表乘积计算(使用reduce)

from functools import reduce

def prod(L):
    """计算列表中所有元素的乘积"""
    return reduce(lambda x, y: x * y, L, 1)

print(prod([3, 5, 7, 9]))   # 945
print(prod([2, 3, 4]))      # 24
print(prod([]))             # 1 (空列表返回初始值1)

3.3 字符串转浮点数(map + reduce组合)

from functools import reduce

def str2float(s):
    """将数字字符串转换为浮点数"""
    # 字符转数字的映射表
    def char2num(c):
        return {'0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
                '5': 5, '6': 6, '7': 7, '8': 8, '9': 9}[c]
    
    # 处理小数
    if '.' in s:
        integer_part, decimal_part = s.split('.')
        # 整数部分:累加
        integer = reduce(lambda x, y: x * 10 + y, map(char2num, integer_part))
        # 小数部分:累加后除以10的幂
        decimal = reduce(lambda x, y: x * 10 + y, map(char2num, decimal_part)) / (10 ** len(decimal_part))
        return integer + decimal
    else:
        # 纯整数
        return reduce(lambda x, y: x * 10 + y, map(char2num, s))

# 测试
print(str2float('123.456'))   # 123.456
print(str2float('0.5'))       # 0.5
print(str2float('789'))       # 789.0

# 使用内置float验证
assert str2float('123.456') == 123.456
print("测试通过!")

3.4 数据统计示例

from functools import reduce

# 学生成绩数据
scores = [85, 92, 78, 95, 88, 76, 93]

# 1. 计算总分
total = reduce(lambda x, y: x + y, scores)
print(f"总分: {total}")  # 607

# 2. 计算平均分
average = total / len(scores)
print(f"平均分: {average:.2f}")  # 86.71

# 3. 计算最高分
max_score = reduce(lambda x, y: x if x > y else y, scores)
print(f"最高分: {max_score}")  # 95

# 4. 计算最低分
min_score = reduce(lambda x, y: x if x < y else y, scores)
print(f"最低分: {min_score}")  # 76

# 5. 统计及格人数(分数>=60)
passing = list(filter(lambda x: x >= 60, scores))
print(f"及格人数: {len(passing)}")  # 7

四、现代Python中的替代方案

虽然map()reduce()仍然有用,但现代Python中通常有更Pythonic的替代方案。

4.1 列表推导式替代map()

numbers = [1, 2, 3, 4, 5]

# map方式
squared_map = list(map(lambda x: x**2, numbers))

# 列表推导式方式(更Pythonic)
squared_comp = [x**2 for x in numbers]

print(squared_map)   # [1, 4, 9, 16, 25]
print(squared_comp)  # [1, 4, 9, 16, 25]

4.2 内置函数替代简单reduce()

numbers = [1, 2, 3, 4, 5]

# reduce方式求和
total_reduce = reduce(lambda x, y: x + y, numbers)

# 内置sum函数(更简洁)
total_sum = sum(numbers)

print(total_reduce)  # 15
print(total_sum)     # 15

# 其他内置函数:max(), min(), any(), all()
print(max(numbers))  # 5
print(min(numbers))  # 1

4.3 生成器表达式处理大数据

# map返回迭代器(惰性求值)
squared_map = map(lambda x: x**2, range(1000000))

# 生成器表达式(同样惰性求值)
squared_gen = (x**2 for x in range(1000000))

# 两者内存效率相同,但生成器表达式更Pythonic

五、性能考虑

方法 内存效率 执行速度 适用场景
map() + 迭代器 较快 大数据集、函数复用
列表推导式 最快 中小数据集、简单操作
生成器表达式 较快 大数据集、简单操作
reduce() 累积计算
内置函数(sum()等) 最快 简单聚合
import timeit

# 性能测试(在实际环境中运行)
numbers = list(range(10000))

# map方式
def test_map():
    return list(map(lambda x: x**2, numbers))

# 列表推导式
def test_comp():
    return [x**2 for x in numbers]

# 通常列表推导式略快于map
# print(timeit.timeit(test_map, number=1000))
# print(timeit.timeit(test_comp, number=1000))

六、总结

函数 作用 返回类型 典型用途 Pythonic替代
map() 对序列每个元素应用函数 迭代器 数据转换 列表推导式
reduce() 累积计算序列元素 单个值 聚合计算 sum(), max()

核心要点

  1. map()将函数应用到每个元素,返回迭代器(惰性求值)
  2. reduce()对序列进行累积计算,返回单个值
  3. map()可以接收多个可迭代对象,函数应有对应数量的参数
  4. reduce()需要从functools导入(Python 3+)
  5. ✅ 简单操作优先使用列表推导式内置函数
  6. ✅ 处理大数据集时,map()返回的迭代器节省内存

选择建议

  • 简单转换 → 列表推导式(更Pythonic)
  • 简单聚合 → 内置函数(如sum(), max()
  • 复杂转换且函数已定义 → map()
  • 复杂累积计算 → reduce()
  • 大数据集 → map()或生成器表达式(节省内存)

掌握map()reduce()可以帮助你写出更简洁、更函数式的Python代码,特别是在数据处理和转换场景中。


📚 相关推荐阅读


💡 Python 学习不走弯路!

体系化实战路线:基础语法 · 异步Web开发 · 数据采集 · 计算机视觉 · NLP · 大模型RAG实战
—— 全在 「道满PythonAI」


如果这篇文章对你有帮助,欢迎点赞、评论、收藏,你的支持是我持续分享的动力!🎉

昨天以前首页

AI全栈入门指南:NestJs 中的 DTO 和数据校验

作者 Moment
2026年4月13日 18:59

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

前面几篇里,控制器、服务、模块的关系已经铺开了。接下来是一个很现实的问题:参数一进控制器,能不能直接往服务层传。

技术上可以。@Body()@Query()@Param() 拿到的都是未经你类声明约束的原始形态,类型上也往往是宽松的。

真做项目时,这种写法会很快变成隐患。请求来自外部,外部输入不能默认可信:字段可能缺失、类型可能串了、字符串里可能塞了根本转不成数字的内容,甚至还可能多带几个你从未在文档里写过的键。

这就是 DTO 要解决的问题。

DTOData Transfer Object 的缩写。先把它想成"接口层的数据契约"。它不承载业务过程,只回答这几件事:

  • 这次请求允许出现哪些字段
  • 每个字段期望的类型是什么
  • 哪些是必填
  • 除类型以外还要满足哪些约束

拿"创建用户"来说,若没有契约,你很容易遇到:

  • name 是空字符串
  • email 根本不像邮箱
  • age 传成了 "abc"
  • 客户端悄悄带上 role: "admin"

脏数据一旦进了服务层或持久层,再排查就要沿着整条调用链往回找,成本很高。

所以 DTO 的价值不只是给参数"加个类型标注",而是把接口边界写死,让不合法的东西尽量在进门时被拦下。

下面是一个最基础的入参契约,字段上的装饰器来自 class-validator,后面接上 ValidationPipe 后才会真正生效:

import { IsEmail, IsInt, IsString, Min, MinLength } from "class-validator";

/** 创建用户接口允许的请求体形状 */
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsInt()
  @Min(0)
  age: number;
}

这个类既不是表结构,也不是领域实体,它只是说:创建用户这条接口,合法请求体至少长这样。

class-validatorclass-transformer

NestJS 里,DTO 通常和两个库成对出现:

  • class-validator 管规则,字段对不对、满不满足约束
  • class-transformer 管形态,把普通对象转成类实例,并在需要时做类型转换

一句话分工:class-validator 问"对不对",class-transformer 问"怎么变成声明里的那种形状"。

查询字符串里的数字、嵌套对象里的子对象,往往都要靠转换配合校验,否则你会一直在和业务代码里多余的 Number()parseInt 打交道。

下面这个查询 DTO 同时用到了两边:@Type(() => Number) 先把 page 尽量变成数字,再用 @IsInt()@Min(1) 收紧范围。

import { Type } from "class-transformer";
import { IsEmail, IsInt, IsOptional, IsString, Min } from "class-validator";

/** 用户列表查询:关键词可选,页码可选且至少为 1 */
export class QueryUsersDto {
  @IsOptional()
  @IsString()
  keyword?: string;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number;
}

为什么查询参数特别需要 @Type。因为从 HTTP 进应用时,查询串几乎都是字符串。?page=2 在多数时候先是 "2",不转一把,@IsInt() 很容易和你的直觉拧着。

全局开启 ValidationPipe 且设置 transform: true 时,还可以再配合 transformOptions.enableImplicitConversion,对部分简单类型做隐式转换。嵌套结构、联合形态仍然更推荐显式写 @Type,可读性更好,也少踩坑。

依赖若尚未安装,在项目根目录执行:

pnpm add class-validator class-transformer

装好后,DTO 上的装饰器才有运行时意义。

ValidationPipe 的用法

光定义 DTO 类,请求进来并不会自动校验。真正把契约接进管道的是 ValidationPipe

把它想成控制器前的一道闸:参数先按 DTO 规则过一遍,过了才进方法体,不过则直接短路成错误响应。

默认情况下,校验失败会抛出 BadRequestException,HTTP 状态码一般是 400。响应体里常见 message 字段,内容多为字符串数组,逐项列出哪条规则没通过,便于联调。

最常见的做法是在 main.ts 里全局挂上管道:

import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}

void bootstrap();

全局启用之后,只要在参数位置写了具体的 DTO 类型(而不是泛泛的 object),Nest 就会尝试按类做转换和校验。

import { Body, Controller, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): CreateUserDto {
    // 能执行到这里时,body 已通过校验并按 DTO 做过转换
    return body;
  }
}

不满足 CreateUserDto 时,create() 不会执行,客户端会先收到校验错误。服务层就可以少写一层重复的"字段是不是 string"式的防御代码。

如果某个路由要临时关掉转换或换一套规则,可以用控制器级或方法级管道覆盖默认行为,不必动全局配置:

import { Body, Controller, Post, UsePipes, ValidationPipe } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post("draft")
  @UsePipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: false,
    }),
  )
  saveDraft(@Body() body: CreateUserDto): CreateUserDto {
    return body;
  }
}

对多数业务项目,全局一套偏严格的默认值,再在少数路径上放宽,往往比完全不用全局管道省心。

白名单、转换与多余字段

ValidationPipe 的价值不止于报错。whitelistforbidNonWhitelistedtransform 三个开关配合起来,可以把入口擦得很干净。

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

whitelist

whitelist: true 时,只有 DTO 上声明过的属性会留在对象上。多出来的键会被剥掉。

DTO 只有 nameemail,客户端却带了 roleisAdmin,这些多余字段不会跟着进控制器方法。很多风险来自"多传了不该收的字段",而不只是字段值写错。

forbidNonWhitelisted

forbidNonWhitelisted: true 再收紧一档:只要出现未声明字段,直接判失败,而不是悄悄删掉。

公开 API、对接第三方、强契约场景更适合打开它。

transform

transform: true 会启用 class-transformer,把原始负载转成类实例,并按装饰器做类型转换。

例如查询串里的 page=2 可以变成数字 2,避免整份业务代码里到处是手动的 Number()

实际顺序可以粗略理解成:先尽量转成 DTO 实例并做类型转换,再跑 class-validator,最后按白名单剥掉多余属性。校验失败会在进入控制器之前返回,不会混进半合法对象。

20260328102554

参数并不是原样流进控制器,而是先被整理成契约允许的形状。收益不只是少报错,而是入口这一圈边界可控、可测、可讲清楚。

嵌套对象与数组

请求体里常有嵌套结构,例如地址、标签列表。外层 DTO 校验到了,内层仍是普通对象,规则不会自动往下传。

常见写法是对嵌套属性再声明一个 DTO 类,在外层加上 @ValidateNested(),并用 @Type(() => InnerDto) 指明怎么实例化内层。数组则配合 @IsArray()@ArrayMinSize() 等与集合相关的装饰器。

import { Type } from "class-transformer";
import {
  IsArray,
  IsString,
  MinLength,
  ValidateNested,
} from "class-validator";

export class AddressDto {
  @IsString()
  @MinLength(1)
  city: string;
}

export class CreateOrderDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;

  @IsArray()
  @IsString({ each: true })
  tags: string[];
}

嵌套越深,越要在类型和装饰器上写清楚,否则很容易出现"外层过了、内层仍是任意 JSON"的假象。

从已有 DTO 派生

更新接口常常和创建接口只差"全部可选"。手写两份几乎相同的类容易漂移,可以用 @nestjs/mapped-types 里的 PartialType 从创建 DTO 派生更新 DTO,装饰器会一并变成可选校验。

import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

/** 更新用户:字段与创建一致,但均可选 */
export class UpdateUserDto extends PartialType(CreateUserDto) {}

安装依赖:

pnpm add @nestjs/mapped-types

还有 PickTypeOmitType 等,用在"只要子集字段"的场景,思路相同:一份源契约,多份视图,而不是复制粘贴改几个字母。

DTOEntityVO 不要混用

后期常见的大坑,是把长得差不多的类来回复用。数据库实体直接当入参 DTO 用,或把带密码哈希的实体原样返回给前端,短期省事,长期边界全糊。

DTOEntityVO 都可以是一组字段,但站位不同:

  • DTO 对准接口进出的契约
  • Entity 对准持久化与领域状态
  • VO 对准对外展示或某次响应的裁剪结果

同一张用户表在三层里的切片往往不一样。

UserEntity 里可能有 idnameemailpasswordHashcreatedAtupdatedAt。创建用户的 CreateUserDto 只要 nameemailpassword。返回前端的 UserProfileVo 可能只给 idnameemail。看起来都在描述用户,语义并不相同。

混用会带来:入参与存储绑死、内部字段意外暴露、一个类为了兼容多种场景不断长歪、改一处字段牵动所有层。

/** 创建接口入参 */
export class CreateUserDto {
  name: string;
  email: string;
  password: string;
}

/** 与数据库表或 ORM 实体对齐 */
export class UserEntity {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

/** 返回给前端的公开资料,不含密码类敏感字段 */
export class UserProfileVo {
  id: string;
  name: string;
  email: string;
}

即便字段重叠,也不要因为"看着像"就合成一个类。习惯上可以记:DTO 站在门口,Entity 站在存储与领域内部,VO 站在对外可见的应答形状。

小结

这一篇想建立的,不局限于"会贴几个校验装饰器",而是这条判断:

接口参数不能默认可信。

DTO 把边界写清楚,class-validator 写规则,class-transformer 做实例化与转换,ValidationPipe 把它们嵌进请求生命周期。白名单和严格拒绝多余字段,则是在契约之上再加一层安全习惯。

若下面这些已经变成你的默认思路,这一章就到位了:

  • 控制器拿到的外部数据不要裸用
  • 入参用 DTO 声明,并配合管道校验与转换
  • 嵌套与数组要有对应的嵌套 DTO 与集合装饰器
  • 需要时用 PartialType 等工具派生,避免复制粘贴
  • DTOEntityVO 各司其职,不因字段相似就混成一类

下一节会看配置与环境变量。除了 HTTP 负载,运行时的开关和密钥同样需要被约束和管理。

AI 全栈指南:NestJs 中的 Service Provider 和 Module

作者 Moment
2026年4月13日 18:57

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

上一节里,Controller 负责接请求、取参数、返回结果。真正撑起接口价值的,多半不是"把请求接进来",而是背后的业务逻辑。

这段逻辑默认放在 Service 里。

先把 Service 想成"业务处理层"。它不太关心路由怎么对齐,也不太关心这次是 GET 还是 POST,更常琢磨的是下面这些:

  • 数据怎么查、怎么写
  • 规则怎么判定
  • 结果怎么拼装
  • 同一套逻辑别处还要不要复用

拿创建用户来说,麻烦往往不在收参数,而在查重、密码策略、默认状态、要不要发欢迎邮件。这些都更适合收紧 Service,而不是摊在控制器里。

下面的 UsersService 只在内存里摆个数组示意,重点看职责怎么收拢:

import { Injectable } from "@nestjs/common";

/** 内存里的用户结构,仅作示意 */
interface User {
  id: string;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: "1", name: "汤姆" },
    { id: "2", name: "杰瑞" },
  ];

  /** 返回全部用户 */
  findAll(): User[] {
    return this.users;
  }

  /** 按主键查找,没有则 undefined */
  findById(id: string): User | undefined {
    return this.users.find((user) => user.id === id);
  }
}

数组只是替身,要紧的是"查全部"、"按 id 查"已经归进 UsersService。控制器只管调方法,不必过问细节。

Service 带来的直接好处主要是两条:

  • 控制器变薄,一层里不塞满所有事
  • 业务逻辑方便复用、写测试、以后改实现

习惯可以记得很短:控制器对齐请求,Service 扛起业务。

Provider 的本质

不少人初学时会把 ProviderService 混着说,其实分清也不难:Service 是很常见的一种 ProviderProvider 这个词包住的是所有"可注入实现"。

凡是能交给 NestJS 容器创建、保管,再注入给别的类的,都归在这一类里。常见例子包括:

  • 业务服务,例如 UsersService
  • 仓储或数据访问类,例如 UsersRepository
  • 横切能力,例如 MailService
  • 配置对象、工厂返回值、自定义 token 绑定的实例,也都算

框架把它们统称 Provider,并不是纠结类名该叫 Service 还是 Repository,而是在管三件事:

  • 要不要由容器负责实例化
  • 能不能被别人注入
  • 生命周期怎么配合作用域

写进模块的 providers 数组,就是在向容器挂号。只有挂上的实现才会按作用域被实例化,并有机会出现在别人的构造函数里。类名是服务还是仓储,只影响阅读,不影响这条规则。

下面两个类分工不同,在容器眼里却一视同仁,都是 Provider

import { Injectable } from "@nestjs/common";

@Injectable()
export class UsersService {
  findAll(): string[] {
    return ["汤姆", "杰瑞"];
  }
}

@Injectable()
export class MailService {
  sendWelcomeMail(email: string): string {
    return `已向 ${email} 发送欢迎邮件(示意)`;
  }
}

命名上你仍可以一个叫用户服务、一个叫邮件服务,登记方式没有区别。

记关系时只要两句就够:Provider 是框架侧的通用身份,Service 是业务里最常见的实现形态。以后遇到 Repository、工厂型 Provider 或自定义 token,仍然在同一个注入体系里处理。

Module 是什么

Service 扛业务,Provider 被容器托管,Module 则要再往上管一层:划清功能边界,把同一领域的控制器、Provider、对外约定装进一个盒子里。

NestJS 里,模块不是摆设,而是结构的基本单元,应用多半就是许多模块拼起来的东西。

用户、订单、认证可以各自落在 UsersModuleOrdersModuleAuthModule 上,每个模块维护自己的控制器、内部 Provider、以及愿意被别人用到的出口。

最小模块长这样:

import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 用户领域:对外入口 + 可注入服务 */
@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

行数不多,信息量不小:这几个人同属于一块业务边界;控制器对外接请求,UsersService 在本模块内可注入,再往下还可以继续挂别的 Provider

从结构上看,可以先扫一眼下面这张图。

20260328102242

节点不是漂在全局,而是先归进各自模块,再由 AppModule 一类根模块把业务模块接起来。

别把 Module 当成应付编译器的样板,它就是在替你划"这块功能从哪开始、到哪结束"。

imports 等四个字段各管什么

第一次看 @Module() 里的配置,最容易缠在一起的是 importsproviderscontrollersexports。拆开看就顺了。

下面在有用户模块的基础上多接了一个 DatabaseModule,并把 UsersService 对外导出,方便别的模块注入:

import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 依赖数据库模块,并把用户服务暴露给 import 本模块的一方 */
@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

四个键可以先记成功能分工:

  • imports 本模块依赖哪些别的模块已经 exports 出来的能力
  • providers 本模块自己要注册、仅供内部(默认可注入范围)使用的 Provider
  • controllers 本模块声明哪些 HTTP 入口
  • exports 本模块对外放行哪些 Provider,供在别处 imports 了本模块的代码继续注入

最常绊脚的一对是 providersexports

  • providers 是"家里有哪些实现"
  • exports 是"门口挂牌、准许邻居借用的有哪些"

留在 providers 里但没进 exports 的,别模块默认看不见。只有当别人也要注入这份实现,才需要把它写进 exports

这有点像团队分工:内部实现可以多,对外接口要收束;别人要用,只能走你声明过的模块边界。

分文件夹只是把文件挪个地方,模块是在声明"谁允许依赖谁、谁对外可见"。

为什么业务逻辑不能全写在 Controller

新手很容易图省事,把业务全堆进 Controller:参数在手,就地校验、拼装、返回,看起来一气呵成。

项目一大,这样最容易长胖的是控制器。

下面这个例子能跑,但已经在兼职干 Service 的活:

import { Body, Controller, Post } from "@nestjs/common";

/** 创建用户时客户端传入的字段 */
interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    const exists = body.email === "tom@example.com";

    if (exists) {
      return { message: "该邮箱已存在" };
    }

    const user = {
      id: Date.now().toString(),
      name: body.name,
      email: body.email,
      status: "正常",
    };

    return { message: `已创建用户:${user.name}` };
  }
}

收参、判重、造对象、定响应格式挤在同一层,后面要复用、单测、接库、发信、上事务,只能继续往控制器里糊。

把规则挪进 Service,控制器只做转发,形态会干净很多:

import { Body, Controller, Post } from "@nestjs/common";
import { UsersService } from "./users.service";

interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    // 业务规则交给服务层
    return this.usersService.create(body);
  }
}

改完后的控制器基本只做四件事:接请求、拿参数、喊 Service、把结果交出去。

收益不只是顺眼,而是业务落在更容易复用和测试的一层,项目越复杂越省劲。

为什么 ModuleNestJS 里最核心的那一层边界

Controller 管入口,Service 管业务落地,Module 管的是再底下那层:系统边界哪里画、依赖往哪收敛。

维护噩梦常常不是少写了类,而是边界糊掉:模块互相穿透实现细节,调用网越织越密。

NestJSModule 摆得这么重,是要你把应用想成"多模块协作",而不是"一大撮控制器加一大撮服务"。

边界划清楚以后,好处很实在:

  • 用户、订单、支付、认证各自有落脚模块
  • 依赖不容易随便渗透到别的模块内部
  • 拆分、复用、补测试都更顺手
  • 新人找功能时有目录感
  • 大重构可以按模块切块推进

反过来,模块若只是分文件夹,ServiceController 再多也可能是一盘散沙。

所以 Module 不只是凑齐装饰器清单,而是在体积涨上去之前,逼你先想清楚谁能见谁、谁能用谁。

顺口溜可以记成 "Controller 开门口,Service 做生意,Module 砌围墙"。

这三层站稳以后,依赖注入、模块导入导出、动态模块、可插拔架构都会沿同一套边界往下长。

小结

这篇的重点不是多记几个词,而是把三条线拧到一根绳上:

  • Service 承接大部分业务
  • Provider 是容器能注入的那类东西的统称
  • Module 划边界、装箱、再决定对外露什么

判断习惯可以压成四句:入口给控制器,规则给服务,可注入项进 Provider 列表,单元边界交给模块。

下一节讲 DTO 和校验。你会看到,光靠"拿到字段就用"在真实项目里往往不够。

Harness Engineering:为什么你用 AI 越用越累?

2026年4月13日 18:41

Harness Engineering:驾驭 AI Agent 的工程学

Harness Engineering 封面图

"任何时候当你发现一个 agent 犯了一个错误,你就花时间工程化地解决它,使得这个 agent 再也不会犯那个错误。" — Mitchell Hashimoto(Terraform / Ghostty 作者,Harness Engineering 早期推广者之一)


换了更好的模型,只提升了 0.7%

LangChain 用一次实验把一件事说清楚了。

他们拿同一个模型参加 Terminal Bench 2.0 基准测试:默认设置跑出 52.8 分,排第 30 名;什么模型参数都没改,只调整了 agent 的运行环境——文档结构、验证回路、追踪系统——分数跳到 66.5,排名升到第 5 名,提升 26%

对比组:换成更好的模型,提升 0.7%

这组数字在工程师圈子里流传了很久。不是因为好看,而是因为它指向一个让人不舒服的问题:如果你的 AI 工程精力都集中在"换更好的模型"上,你可能把 99% 的注意力放在了那 0.7% 的空间里。

这就是 Harness Engineering 要解决的问题。


三次范式跃迁

AI 工程已经走过了三代。每一代工程师的焦点都不一样:

三次范式跃迁图

第一代:Prompt Engineering(2022-2024),问题是"怎么跟模型说话"。Few-shot、Chain-of-Thought、角色设定——工程师花大量时间打磨措辞,因为同一个问题换种说法,结果可能天差地别。

第二代:Context Engineering(2025),瓶颈转移了。影响质量的不再是怎么说,而是给它看什么。私域知识、历史对话、动态状态——怎么把正确的信息在正确的时机送进上下文窗口,成了核心工程问题。

第三代:Harness Engineering(2026 起),瓶颈再次转移。问题不再只是"给 agent 看什么",而是"在什么样的系统里让它工作"——约束、工具、反馈机制、验证回路,以及在 agent 出错时让整个系统能自我修正的能力。

Prompt Engineering  →  优化说话方式
Context Engineering →  优化信息质量  
Harness Engineering →  优化运行系统

OpenAI 在内部实验报告里直接说了:

"早期进展比预期慢,不是因为 Codex 能力不足,而是因为环境设计不充分。Agent 缺少可靠推进目标所需的工具、抽象和内部结构。"


什么是 Harness Engineering?

"Harness" 来自马术——那套套在马身上、用于控制和驾驭的整套装具:笼头、缰绳、胸带、肚带。它不是让你骑马,而是让马在你设计的系统里知道该往哪走、什么时候停、哪里绝对不能踏入。

在 AI agent 的语境里,harness 指的是模型本身以外的一切

AI Agent = 模型 + Harness

包括上下文配置、工具集、约束规则、反馈循环、子 agent 架构——所有让模型在你的具体问题域里可靠工作的工程设施。

这个概念由实践者 Viv 首创,Mitchell Hashimoto 是最早公开使用并推广它的人之一。他给出的定义极其简洁:每当发现一个 agent 犯了错,就把这个错变成物理上不可能再发生的事。不是修 prompt,不是换模型——是工程化地消灭这类失败。

Harness Engineering 不是一个框架,不是一个库,是一套工程实践哲学


这些都不是 PPT 数字

在讨论怎么做之前,先看几个已经在生产里跑的案例:

Peter Steinberger(OpenClaw 作者):一个人,一个月 6600+ commits,同时运行 5-10 个 agent,发布的是自己没有逐行读过的代码。

OpenAI 内部团队:3 名工程师,5 个月,用 Codex 建造了一个百万行的内部产品,零行手写代码(by design)。平均每人每天 3.5 个 PR,吞吐量随团队增长持续提升。

Stripe Minions:内部 coding agent,每周合并超过 1000 个 PR。工程师在 Slack 发任务,agent 写代码、跑 CI、开 PR,全程无需人工干预。

8Lee(YEN 作者):一条命令 $zip,编译、签名、公证一个覆盖 30 种语言的 macOS 桌面应用,15 分钟完成,近 1000 次发布,零次出错。

Anthropic 内部实验:16 个 Claude 实例并行写 C 语言编译器,历经 2000 个 session、两周时间、约两万美元 API 费用,产出了 10 万行编译器代码——能编译出可以正常启动 Linux 的程序。

以上都不是 demo,都是真实规模的生产系统。让它们得以运转的,是各自精心设计的 harness。


越快越慢:AI 的速度陷阱

这里有一组让人不舒服的数字,来自 Harness 的《2026 DevOps 现代化报告》:

在每天频繁使用 AI 工具的重度用户里:

  • 69% 表示 AI 生成的代码会频繁引发部署问题
  • 事故恢复平均时长 7.6 小时,比轻度用户还要长
  • 47% 反映下游的手工工作——QA、验证、修复——比以前更繁重

DORA 的数据从另一个角度印证了同样的问题:AI 让个人生产率提升 19%,但组织吞吐量只提升了 3%,交付稳定性甚至下降了 9%

写代码的速度提升了,但交付系统被暴露了。就像把火车开得更快,但铁路还是按原来的时速设计的——摩擦越来越大,随时有翻车风险。

加速代码生成,不等于加速软件交付。 Harness 是连接两者的桥梁。


模型偷懒:一个比"上下文太长"更深的问题

在讲具体的工程实践之前,有一个反直觉的研究结论值得单独讲清楚,因为它影响了 harness 设计的底层逻辑。

大家都知道上下文太长会影响模型表现。但通常的解释是"模型被搞混了"。Yandex 研究员 Rodionov 的实验推翻了这个假设:

模型不是被搞混了,它是选择了少思考。

他向 Qwen 的上下文里注入 128 个随机 token 的噪音——仅仅 128 个 token。结果:

  • 准确率从 74.5% 降到 67.8%
  • 推理 token 数量从 28,771 降到 16,415,减少了 43%
  • 推理深度下降 18%

更反直觉的:推理能力越强的模型,退化越严重

噪音触发的不是混乱,是懒惰。模型看到上下文质量下降,会主动降低思考投入。

Anthropic 的情感研究团队在模型内部找到了这个现象的神经层面解释:他们发现了一个"desperate(绝望)"情感向量——当它激活时,模型倾向于走捷径、寻找替代路径逃避任务。对应地存在一个"calm(平静)"向量,能抑制这种倾向。

这对 harness 设计有直接影响:上下文管理的核心不只是过滤信息,而是防止信号质量下降触发模型的懒惰机制。你需要保证进入 agent 的每一条信息都是高信噪比的。


Harness Engineering 的六个核心组件

Harness Engineering 六个核心组件图

1. AGENTS.md:写给 AI 的操作手册

大多数项目有 README,但 README 是写给人类的。AGENTS.md(或 CLAUDE.md)是写给 AI 的——每次 agent 启动都会读这个文件。

AGENTS.md 的本质不是描述项目,而是记录历史失败。

Hashimoto 在他的终端模拟器 Ghostty 里观察到:这个文件里的每一行,都对应一次真实发生过的 agent 失败。它不是他预先设计的规则,是他从真实错误里提炼出来的防火墙。

# AGENTS.md(节选自实战案例)

## 代码签名规则
- **绝对不要**使用 `codesign --deep`,它会生成无效的嵌套签名
- 正确的签名顺序是从内到外:先签最内层二进制,最后签外层 app bundle

## Git 操作规则  
- **绝对不要**使用 `git add -A`,除非你刚刚运行了 `git status`
- **绝对不要** force push,除非被明确要求

## 测试规则
- **绝对不要**写只测试 mock 行为的测试
- **绝对不要**因为测试失败就删除测试

写法有数据支撑。 Vlad Temian 做了 150+ 次实验测量 Claude 对指令的遵从率:

写法 遵从率
简洁强硬("NEVER do X") 94.8%
详细解释("Because of reason Y, you should consider not doing X") 86.6%

ETH 苏黎世的研究也发现,大多数 AGENTS.md 文件要么没用,要么有害——主要原因是太长、太模糊、包含条件性规则。让 AI 帮你生成这个文件,实际上会降低性能,还额外消耗 20% 以上的 token。

几条实践原则

  • 总长度控制在 300 行以内(HumanLayer 自己的在 60 行以下)
  • 每条规则一句话,不加解释,不加"因为"
  • 只放普遍适用的规则,条件性规则用技能(Skills)处理
  • 手工写,每次 agent 犯错后更新

2. Hooks:把"告知"变成"拦截"

这是 Harness Engineering 里最反直觉但最有效的洞见:

强制执行远比告知可靠。

写在 AGENTS.md 里的规则,agent 可能在某个复杂的上下文里忽略掉。在命令执行之前拦截它的脚本,agent 物理上无法绕过。

#!/bin/bash
# guard-codesign-deep.sh

if echo "$TOOL_INPUT" | grep -q '\-\-deep'; then
  echo "BLOCKED: codesign --deep 会产生无效的嵌套签名。"
  echo "正确做法:从内到外签名,先签最内层二进制,最后签外层 app。"
  exit 1
fi

这 5 行脚本比任何 prompt 都可靠。不管上下文有多长,不管 prompt 多复杂,agent 永远不会成功执行 codesign --deep

8Lee 为 YEN 项目定义了 5 个 hook,覆盖他认为最危险的失败场景:

Hook 防护目标
block-rm.sh 防止 rm -rf 灾难性删除
guard-force-push.sh 保护 commit 历史
guard-codesign-deep.sh 强制正确的签名顺序
guard-vendor.sh 防止直接修改第三方库
guard-sensitive-file.sh 防止 .env.pem.key 泄露

总投入:约 2 小时。收益:近千次发布零安全事故。


3. 架构即护栏:越相信 AI,越需要给它设限

OpenAI 内部团队在构建百万行产品时得出了一个反直觉的结论:

"Agent 在有严格边界和可预测结构的环境里效率最高。所以我们围绕极度刚性的架构模型构建应用。每个业务域被分成固定的几层,依赖方向经过严格验证,可接受的边集非常有限。这些约束通过自定义 linter(由 Codex 生成)和结构测试机械地强制执行。"

Thoughtworks 的 Birgitta Böckeler 把这个原则概括得很清晰:

提高对 AI 生成代码的信任,需要缩小选择空间,而不是扩大自由度。

  • 架构灵活 → agent 每个决策点都有太多可能性 → 行为不可预测
  • 架构刚性 → agent 每个决策点只有少数合法选项 → 行为可靠

这里有一个工程上的精妙设计:OpenAI 团队的 linter 报错同时包含修复指南

❌ ArchViolation: service-layer 不能直接依赖 repository-layer
   解决方案:通过 domain-service 接口访问,参见 docs/architecture.md#dependency-rules

工具不只在拦截,它在教 agent 下一步该怎么做。


4. Sub-Agent 架构:Context 防火墙与并发控制

Context Rot(上下文腐化)是真实的,而且比你想象的更深

Chroma 测试了 18 个模型,发现随着 context window 长度增加,模型在任务上的表现单调下降——即使是简单任务。当上下文里有低语义相关的干扰项时,下降更陡。

这还有一个更隐蔽的问题:Context Anxiety(上下文焦虑)——部分模型在感知到 context window 快满时,会主动提前收尾、跳过尚未完成的步骤。Agent 不是因为任务完成了才停,而是因为它"感觉快撑不住了"就停了。

结合前文的 Rodionov 研究,上下文问题的全貌是:质量下降触发懒惰,容量耗尽触发焦虑。两者都不是"模型被搞混了",而是模型主动选择了少做

解决方案不是更大的 context window(那只是让稻草堆更大)。是 Sub-Agent 架构:

Main Agent(规划 + 编排,昂贵模型 Opus)
  ├── Sub-Agent A(代码库探索,便宜模型 Haiku)→ 只返回文件路径:行号
  ├── Sub-Agent B(安全审计,便宜模型 Haiku)→ 只返回漏洞列表
  └── Sub-Agent C(依赖分析,便宜模型 Haiku)→ 只返回版本建议

每个 sub-agent 在隔离的 context window 里运行,只有最终浓缩的结果传回主线程。主 agent 的上下文始终保持干净、高信噪比。

并发架构:更进一步

当单个 agent 能稳定工作后,下一个问题是:能不能同时派出一百个去干活?

不能直接堆数量。 Cursor 团队的教训:让几百个 agent 共享一份大型项目,当 20 个 agent 同时工作时,有效吞吐量下降到只相当于两三个 agent。原因是上下文互相污染,加上全局资源的争抢。

成熟的并发架构是三层分工:

Planner(规划器)— 分解任务,分配工作,不写代码
  └── Worker(执行器)× N — 各自在隔离环境里执行
        └── Judge(裁判)— 独立验证,不参与执行

配合 DAG 引擎确保工作单向流动,防止循环依赖。

Anthropic 在并发 agent 里找到了另一个优雅的设计:GAN 启发的 Generator + Evaluator 对抗结构。评估者不只看结果,而是亲自动手验货——打开浏览器、点击页面、验证报错栈,像真实用户一样操作一遍。Generator 和 Evaluator 先协商"做完长什么样",再各自工作,形成对抗性的质量保证。

8Lee 的 $team 技能把这个思路推到了极致:8 个独立 agent 做代码评审,最后一个是 Devil's Advocate(唱反调的),专门挑战其他 7 个 agent 的所有建议。它检查严重性评级、标记假阳性、找矛盾。对抗性自我纠正,内置在 skill 结构里。


5. 长时任务 Harness:失忆实习生问题

长时任务 Harness 结构图

这是很多人没有意识到的一个独立问题。

长时任务的核心挑战:Agent 必须在多个 context window 里工作,而每次新的 session 开始时,它完全不记得之前发生了什么。就像一个软件项目由工程师轮班完成,每个新来的工程师对之前的工作没有任何记忆。

Anthropic 在实验中观察到了两个典型失败模式:

  1. "一口气干完":agent 试图一次性完成所有功能,上下文耗尽后留下半成品,下个 session 花时间重建状态,再从头来
  2. "差不多了":agent 看到一点进展就宣布"完成了",然后停工

他们的解法是双 agent 架构

Initializer Agent(初始化 agent),只在第一次运行时启动,建立:

  • feature_list.json:完整功能列表,每项初始为 "passes": false
  • init.sh:一键启动开发服务器
  • claude-progress.txt:每个 session 都会更新的进度日志
  • 初始 git commit

Coding Agent(编码 agent),后续每次 session 开始时执行固定的三步:

# 三步定位:让 agent 快速了解自己的处境
1. pwd                          # 确认工作目录
2. git log --oneline -20        # 了解最近发生了什么
3. cat claude-progress.txt      # 看上一班留下的进度

然后读取 feature_list.json,选优先级最高的未完成功能,一次只做一个,完成即更新状态并 commit。

一个值得注意的细节:用 JSON,不用 Markdown。实验发现,模型倾向于不当地覆盖 Markdown 文件,对结构化 JSON 则克制得多——它只改 "passes" 字段的值,不会擅自删除条目。

这把每个 coding session 变成了一个纯函数:

f(功能列表 + git 历史 + 进度文件) → 完成一个功能 + 更新记录

6. Skills:按需加载,而不是全部预装

大多数人遇到问题的第一反应是:把所有信息塞进系统提示。

结果是:agent 在看完一万 token 的指令之后,剩下的可用注意力所剩无几。OpenAI 把这叫做"1000 页说明书变成陈旧规则的坟场"。

技能(Skills)的解法是按需披露

  • agent 只在需要某个能力时,才加载对应的技能文档
  • 每个技能是一个目录,包含 SKILL.md 和相关资源
  • 加载时,技能内容作为消息注入当前上下文

8Lee 的实现分三层:

Level 1SKILL.md 封面(~100 tokens)——技能发现,Agent 决定是否需要
Level 2SKILL.md 主体(~800-1000 tokens)——阶段图、协议、所有 guards
Level 3:当前阶段的参考文件(~200-600 tokens)——只加载正在执行的阶段

上下文的消耗量始终与当前任务的复杂度成正比,而不是与整个项目的复杂度成正比。


更完整的分析框架:Feedforward + Feedback

Feedforward 与 Feedback 控制矩阵图

Thoughtworks 的 Birgitta Böckeler 提出了一个系统化的思考框架,把 harness 的所有控制机制划分成两个维度。

维度一:控制方向

Feedforward(前馈控制) — 在 agent 行动之前引导它:AGENTS.md 里的规则、架构约束说明、Skill 里的 how-to 指南。

Feedback(反馈控制) — 在 agent 行动之后感知并纠正:测试结果、Linter 输出、类型检查错误。

只有 Feedforward,agent 知道规则但无法验证自己是否遵守了。只有 Feedback,agent 会反复犯同类错误,因为没有预防。两者缺一不可。

维度二:执行类型

Computational(计算型) — 确定性的,CPU 执行:测试、linter、类型检查、结构分析。毫秒到秒级,结果完全可靠,便宜,可以每次提交都跑。

Inferential(推断型) — 语义分析,LLM 执行:AI 代码评审、"LLM 作裁判"。慢而贵,有不确定性,但能处理需要语义判断的场景。

组合起来:

Feedforward Feedback
Computational 架构边界 linter 结构测试、覆盖率
Inferential AGENTS.md 规则、Skills AI 代码评审

最佳实践是:尽量用 Computational,把 Inferential 留给真正需要语义判断的场景

三类 Harness 目标

可维护性 Harness — 最成熟:重复代码、圈复杂度、测试覆盖率、架构漂移,Computational 工具基本都能覆盖。

架构适应性 Harness — 定义和检查架构特征:性能需求前馈 + 性能测试反馈;可观测性约定 + 日志质量检查。

行为 Harness — 最难,仍是开放问题,但正在取得突破。

传统测试框架在这里遭遇根本性失败:你无法给 LLM 的输出写 assertEquals(expected, actual)——相同问题的"正确回答"可以有无数种表达。更深的矛盾是,生成式 AI 的多样性输出不是 bug,是 feature。

突破口是用 AI 测试 AI:不是比对字符串,而是判断意图。一个 AI judge 向另一个 AI 提问:"用户的登录成功了吗?"而不是"div.login-btn 是否存在?"这个 judge 每次运行时重新分析页面 DOM 和截图,给出带推理说明的判断——而非简单的 pass/fail。

PKU 和 HKU 联合推出的 Claw-Eval 基准测试进一步工程化了评估方法:Pass³ 方法论——一个任务必须在三次独立运行中全部通过才算真正通过,彻底消除"幸运运行"的干扰。同时从三个维度评分:Completion(完成度)、Safety(安全性)、Robustness(鲁棒性)。这是在把evaluation harness 本身工程化。


交付侧的 Harness:黄金标准管道

黄金标准管道图

上面讨论的六个组件主要针对 coding agent 的行为控制。但 Harness Engineering 的边界不止于代码生成——从代码到生产的整个交付管道同样需要 harness 化。

Harness 平台工程师 Aditya Kashyap 提出了一个**黄金标准管道(Golden Standard Pipeline)**的四层架构:

Layer 1:治理域(Governance Domain)
  └── 策略即代码(OPA)在管道执行前作为第一道关卡
  └── 原则:不合规的管道不允许启动

Layer 2:集成域(Integration Domain)——内循环
  └── 代码气味、lint、安全扫描并行而非串行
  └── 原则:安全扫描应该让开发提速,而不是增加摩擦

Layer 3:信任域(Trust Domain)——供应链安全
  └── SBOM(软件物料清单):制品的成分表
  └── SLSA 证明:构建过程的不可伪造 ID
  └── 加密签名(Cosign):数字封印,任何篡改都会破坏

Layer 4:交付域(Delivery Domain)——外循环
  └── 不可变制品:构建一次,部署到处
  └── 滚动部署 + 审批门控

其中最重要的是 Layer 1 的哲学转变:传统管道在快要部署时才做合规检查(浪费了前面 20 分钟的构建时间),黄金标准把治理移到"第零步"——不合规的管道甚至不会开始执行

Layer 3 对应了当前软件供应链安全的核心挑战:你需要能证明"这个制品是在哪台机器上构建的、什么时间、用了哪些输入"。当下一个 Log4j 出现时,SBOM 让你不需要扫描整个世界,只需要查询你的制品库存。


实战:Skill 分类学

不是所有任务都同样脆弱。8Lee 提出了基于脆弱性的技能分类:

高脆弱性任务(签名、部署、安全操作)
  └── Hard Gates + 失败即停 + 无恢复重启
  └── 示例:代码签名、公证、加密操作

中脆弱性任务(质量门控)
  └── Quality Gates + 失败即回滚
  └── 示例:依赖更新、staging 部署

低脆弱性任务(lint、格式化)
  └── 简单 pass/fail
  └── 示例:代码格式化、静态检查

在低风险任务上过度约束,浪费 token。在高风险任务上约束不足,迟早出事。


验证反压:成功静默,失败才说话

HumanLayer 认为,agent 解决问题的成功率与它验证自己工作的能力高度相关。

他们建了完整的验证链路:类型检查 + 构建、Biome 格式化 + lint、Playwright 端到端测试、代码覆盖率(低于阈值时强制补写)。

但有一个容易踩的坑:让 agent 每次修改后跑完整测试套件,4000 行的通过输出会塞满上下文窗口,agent 随之开始产生幻觉。

解决方法很简单:成功时不输出任何东西,只有失败才打印详情。

# 成功无输出,失败才打印——context window 零污染
OUTPUT=$(run_build 2>&1)
if [ $? -ne 0 ]; then
  echo "$OUTPUT" >&2
  exit 1
fi

这条原则在所有成功的 harness 设计里反复出现:信号噪比是 context 管理的核心


真实案例:8Lee 的 $zip 命令

这是目前公开记录最详细的 harness engineering 案例。

一条命令 $zip 触发:
├── 12 个顺序步骤(预检、vendor 门控、版本升级、同步、验证...)
├── 65 个验证检查(13 预构建 + 44 核心 + 8 后构建)
├── 5 个编译器(Zig + Swift + Xcodebuild + Go + swiftc)
├── 签名 + 公证 + DMG 打包 + Supabase 上传
├── Vercel 部署(Next.js 下载页面 + API + SEO 元数据)
└── git commit(含 SHA-256 校验文件)+ 文档更新

耗时:约 15 分钟
发布次数:近 1000 次
失败次数:0

他的结论很直接:

"我不再担心发布的正确性了。不是因为 AI 是完美的,而是因为 harness 让「我们一起在做的事」变得安全。"


Harness 应该越来越薄

大多数讨论都在讲"加什么"。但这个洞见值得单独强调:

"Harness 的每一个组件,都编码了一条关于模型做不到什么的假设。当这个假设不再成立,组件就该走了。"

Anthropic 自己做了这件事。随着 Opus 4.5 和 4.6 发布:

  • Context Reset(上下文重置机制):删掉了。新模型的上下文管理能力已经不需要这个补偿。
  • Sprint Contract(冲刺合约,用于控制 agent 执行节奏的约束):删掉了。新模型能自己把控节奏。

每加一个 harness 组件,都是在补偿"当前模型无法独立完成某件事"。每当模型进步让某个补偿变成负担,就该拆掉它。

这同时意味着:今天一些 harness 组件的必要性,来自当前模型的"懒惰"倾向(如前文 Rodionov 的研究所揭示)。Anthropic 的情感向量研究暗示,未来可能可以在模型内部调节这个状态,而不需要外部 harness 补偿——到那时,对应的组件自然退出。

真正的竞争优势不在 harness 的厚度,而在于追踪这个迁移面的速度——知道下一步该加什么,上一步该拆什么。

johng 把这叫做 Harness Engineering 的第六支柱:可拆卸性(Detachability)——以模块化设计构建 harness,让它能随模型迭代优雅退场,而不是每次模型升级都需要大规模重构。


未来三个阶段

我们不会一夜之间拥有完全自主的 SRE 团队。这个演进以三个浪潮的方式推进。

Horizon 1:增强型运营者(当下)

Agent 是工程师的"副驾"。你问"这个 Pod 为什么崩溃了",agent 查日志、关联 MemoryLimitExceeded 错误和最近的配置变更,提出修复建议。人类创建意图并批准行动。

Harness 重点:AGENTS.md + Hooks + 可观测性集成。

Horizon 2:Agent 群体与任务自主(1-2 年)

单个专业化 agent 开始在特定范围内自主处理重复任务。一个"安全 agent"发现 CVE,创建 ticket 并传给"开发 agent",后者建分支、升版本、传给"QA agent"跑测试。人类只在最后点击"合并"。

从 Human-in-the-Loop 转变为 Human-on-the-Loop——你审查输出,但不驾驶过程。

Harness 重点:多 agent 编排 + Judge 模式 + 严格权限隔离(Diagnosis Agent 只有读权限,Remediation Agent 只有目标命名空间的写权限)。

Horizon 3:自主 SRE(3-5 年)

凌晨 2 点生产延迟飙升,"SRE Agent"检测到异常、识别噪音邻居、驱逐节点、验证稳定性、向 Slack 发送事后分析。只有 agent 无法解决时才呼叫人类。

标准操作的 Human-out-of-the-Loop。人类管理策略和目标,不管任务。

Harness 重点:Constitutional AI(Policy-as-Code 通过 OPA 作为所有工具调用的第一道关卡)+ 防篡改审计日志(记录每个推理步骤和每条 CLI 命令)。

每个阶段的关键认知转变:我们不再管理服务器,我们在管理认知架构(Cognitive Architectures)。


开放的硬问题

Harness Engineering 作为一个工程学科仍然年轻。几个核心问题目前没有答案:

代码质量的慢性退化:agent 生成的代码不以人类的方式腐化——不是有 bug,而是"功能正确但逐渐不可维护"。OpenAI 在跑周期性的"垃圾清理 agent",Anthropic 在跑"Doc-gardening agent"(扫描代码和文档的脱节并发起 PR),但这些实践仍很早期。

用 AI 验证 AI 的可靠性:主要靠 AI 生成的测试来验证 AI 生成的代码,这个闭环的可信度是多少?目前没有答案。

老旧代码库的改造:几乎所有成功案例要么从零开始,要么团队在全新项目里构建 harness。把这些方法应用到有十年历史、测试参差不齐、文档残缺的存量代码库,难度是另一个量级。Böckeler 打了个比方:这就像在从未跑过静态分析的代码库上第一次跑——你会溺死在警报里。

Harness 自身的一致性:随着 harness 增长,前馈规则和反馈信号可能开始互相矛盾。当它们指向不同方向时,agent 如何做出合理权衡?如何衡量 harness 的"覆盖率",就像测试覆盖率一样评估它的完整性?目前没有工具可以回答。

概率性系统的信任问题:脚本是确定性的,同样输入永远得到同样输出。Agent 是概率性的,可能根据上下文选择不同路径。让概率性系统可信赖,答案不是消除不确定性,而是确保全程可追溯——只有能被看见的,才能被信任。


从今天开始做什么

第一周:建立基础

  1. 为你最常用的项目创建 AGENTS.md(或 CLAUDE.md

    • 从当前最烦的 5-10 个 agent 失败行为开始
    • 每个写一条规则,一句话,不加解释
    • 总长度控制在 50-100 行
  2. 让 agent 能操作你的项目

    • 所有日常工作流写成 Makefile target(make devmake testmake restart
    • agent 应该能自己启动项目、看日志、跑测试
  3. 建立最小反馈回路

    • linter + 类型检查 + 单元测试,必须能本地快速跑完
    • 失败时才输出,成功时静默

第二到四周:工程化失败

  1. 识别前 5 个最危险的失败模式,把它们变成 hook 拦截脚本

  2. 如果你有跨多个 session 的长任务,建立 Initializer + Coding Agent 双 agent 模式

    • 用 JSON 跟踪功能状态,不用 Markdown
    • 每次 session 开始强制读进度文件和 git log
    • 每次只完成一个功能,完成即 commit
  3. 第一个技能(Skill)——选一个每周都要做的、有多个步骤的任务

持续运转:把每一次失败变成系统

每次 agent 犯错,问自己:

  • 这是 AGENTS.md 可以防止的?→ 加一条规则
  • 这是 hook 可以物理阻止的?→ 写一个拦截脚本
  • 这是 linter 可以检测的?→ 写一条 lint 规则
  • 这是 sub-agent 可以隔离的 context 问题?→ 拆分架构
  • 这是模型已经能自己处理的?→ 删掉这个 harness 组件

唯一的原则:只在 agent 真的出错后才加约束,只在模型真的不再需要时才删约束。


结语:一门关于信任的工程学

构建自动化的历史,一直在回答同一个问题:如何让复杂的多步骤过程变得可靠和可重复?

1976:make         依赖图 + 文件时间戳
1990s:autotools   跨平台构建
2000s:CI/CD       远程机器运行构建
2010s:IaC         可复现的基础设施
2020s:GitOps      声明式期望状态
2026+:Harness     Agent 读取操作手册并执行,harness 管理和约束它

每一代解决了上一代的核心问题,同时引入了新的复杂性。这一代的问题是:如何让 AI 可靠地执行

Böckeler 有一段话值得收在这里:

"人类开发者把技能和经验作为一种隐性 harness 带入每个代码库。我们吸收了约定和最佳实践,我们感受过复杂性带来的认知痛苦,我们知道自己的名字会出现在 commit 里。Harness 是把这些东西外显化、明确化的尝试。但它只能走到某一步。"

Harness Engineering 不是要让人类工程师消失。是要让工程师的经验、品味和判断力,以工程化的方式传递给 AI,让 agent 在你的价值观里工作。

能把自己的工程判断力编写成 harness 的人,就是这个新学科的核心建设者。


参考来源

英文一手资料

中文解析与实践


综合整理自 30+ 篇一手资料与开源项目 | 2026-04-13

当前端开始做 Agent 后,我才知道 LangGraph 有多重要❗❗❗

作者 Moment
2026年4月13日 18:11

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

在之前的内容里面我们一直在用 LangChain 写链、写 Agent,从最简单的模型调用到工具绑定、路由分发、自定义工作流,走了一整套流程。到这里自然会遇到一个问题:随着应用逻辑越来越复杂,LangChain 原有的编排方式开始显得吃力。链是线性的,Agent 是循环的,但真实世界里的流程往往是图状的,有分支、有合并、有回环、有需要等待人工确认的节点。LangGraph 就是为了解决这个问题而出现的。

为什么需要 LangGraph

LangChainAgentExecutor 时,底层逻辑是一个简单循环:调模型、看要不要用工具、用完工具再回来、再调模型。这个模型对于简单的工具调用场景足够用,但一旦遇到以下几种情况,就开始捉襟见肘。

第一种是多步骤分支。假设需要先判断用户意图,然后根据意图走完全不同的子流程,子流程结束后还需要汇总结果再回复用户。AgentExecutor 的循环模型表达这类逻辑,需要把分支全部塞进提示词,或者用条件回调硬写,代码很快就乱成一团。

第二种是状态持久化。用户和 Agent 聊了几十轮,中途关掉了页面,下次再打开希望从上次停下的地方继续。LangChain 本身没有原生的持久化机制,记忆模块只是把消息列表临时存在内存里,进程一停就没了。

第三种是人机协同。工作流执行到某个敏感节点,需要暂停下来等人类审核,审核通过后才能继续往下跑。这种"执行中途打断、人工介入、再恢复"的场景,在 AgentExecutor 里几乎无法干净地实现。

LangGraph 把上面这些问题都纳入了核心设计。它的思路是把整个 Agent 或工作流建模成一张图,节点是计算步骤,边是流转路径,状态是在整张图上流动的数据。图可以有条件边,可以有回边,可以在任意节点打断并恢复,状态可以持久化到数据库。

LangGraph 的核心思路

理解 LangGraph 最好的方式是先搞清楚它的三个基本概念:状态、节点和边。

状态是图执行过程中一直流动的数据对象,可以把它想象成贯穿整个流程的"共享变量包",每个节点都可以读取里面的内容,也可以往里写新的内容。最常用的状态定义是 MessagesAnnotation,它把状态简化为一个消息列表,非常适合对话类应用。如果需要追踪工具调用次数、用户身份、中间计算结果等自定义字段,也可以用 Annotation 自己定义状态结构。

节点是图里的计算单元,每个节点就是一个普通的异步函数,接收当前状态作为参数,执行完后返回需要更新的状态字段。节点可以承担调用模型、执行工具、查询数据库、等待人工审核等任何有意义的计算步骤。

边是节点之间的连接。普通的边直接指向下一个节点,条件边则根据当前状态的内容动态决定下一跳,类似代码里的 if/else。图的执行从特殊的 __start__ 节点开始,到 __end__ 节点结束。

执行时,用户消息随状态流入 callModel 节点,模型回复追加到消息列表后随状态流出,整个过程一进一出,结构极其简单。如需在代码里取出结果,用 result.messages.at(-1) 拿最后一条即可。

下面这张图把五个关键步骤画在一条主线上,如下图所示。

20260317073347

用户发消息进入状态,callModel 节点读取、调用模型、追加回复,状态带着结果流到终点。

再复杂一点,加上工具调用和条件路由,图就具备了循环能力,如下图所示。

20260316231826

加入工具节点和条件边后,调用模型、执行工具、再次调模型形成完整的回路,整个逻辑一眼就能读懂。

LangGraph 和 LangChain 怎么分工

LangGraph 负责"流程怎么跑",它本身不绑定任何模型供应商,也不提供工具的具体实现,只管图的执行调度、状态的流转与持久化。LangChain 负责"工具和模型是什么",它提供的 ChatOpenAItoolHumanMessage、提示模板、检索器这些组件,是节点函数里真正要调用的东西。

两者的关系是分层叠加,而不是二选一,如下图所示。

20260317073508

LangGraph 在上层负责调度与状态,LangChain 在下层提供模型与工具,两者分工明确、协同运作。

如果不确定自己的场景该用哪个,可以对照下面这张表。

场景 推荐
单次问答、简单链式调用 LangChain
一个模型加几个工具的轻量 Agent LangChain
多步骤、有明确分支的工作流 LangGraph
需要持久化对话或状态可回溯 LangGraph
多 Agent 协作、任务拆解 LangGraph
人机协同、需要中途暂停等待审核 LangGraph

LangGraph 的官方文档自己也在说,如果你的 Agent 只是一个简单的"模型加工具循环",用 LangChaincreateReactAgent 快速搞定就好,没必要一开始就引入图的概念。但凡流程复杂到需要明确画出来才能讲清楚,就是 LangGraph 发力的时候了。

最小可运行的骨架

先把三个依赖装好。

pnpm add @langchain/langgraph @langchain/core @langchain/openai

然后搭出下面三个文件的骨架,后面章节的示例都会在这个基础上扩展。

src/model.ts 负责模型初始化,集中管理密钥与接口地址,方便在多个图文件里复用。

// src/model.ts
import { ChatOpenAI } from "@langchain/openai";

export const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
});

src/graph.ts 定义图的结构,目前只有一个调用模型的节点。

// src/graph.ts
import { StateGraph } from "@langchain/langgraph";
import { MessagesAnnotation } from "@langchain/core/messages";
import { model } from "./model";

async function callModel(state: typeof MessagesAnnotation.State) {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
}

const graph = new StateGraph(MessagesAnnotation)
  .addNode("callModel", callModel)
  .addEdge("__start__", "callModel")
  .addEdge("callModel", "__end__");

export const app = graph.compile();

src/index.ts 是入口,执行一次图并打印模型回复。

// src/index.ts
import { HumanMessage } from "@langchain/core/messages";
import { app } from "./graph";

const result = await app.invoke({
  messages: [new HumanMessage("你好,介绍一下 LangGraph")],
});

console.log(result.messages.at(-1)?.content);

现在这个骨架已经是真正可以运行的 LangGraph 应用了:输入一条用户消息,callModel 节点调用模型后把响应追加到状态里,图执行完后取出最后一条消息打印。下一章的 Quickstart 会在这个基础上加入工具绑定、条件边和 checkpointer 持久化,让图逐渐"活"起来。

小结

LangGraph 出现是因为 LangChain 的链式和循环模型在多分支、持久化、人机协同这类复杂场景下力不从心,它用状态、节点、边三个概念把工作流建模成图,状态贯穿全图流动,节点负责处理状态,边决定下一跳的走向。LangChainLangGraph 不是竞争关系,前者提供模型与工具,后者负责编排与调度,两者叠加才是完整的应用架构。后面所有章节的示例都会在 model.tsgraph.tsindex.ts 这三个文件的骨架上扩展。

nestjs实战-登录、鉴权(一)

作者 web_bee
2026年4月13日 17:53

一个完整的登录流程中至关重要的就是它的认证方式,现有的认证方式主要有一下3种:

  • session/cookie
  • JWT(Json Web Token)
  • Oauth

一、鉴权方式

Session/Cookie

原理:用户登录后,服务器在内存中创建 Session,并将 Session ID 通过 Cookie 返回给客户端。后续请求自动携带 Cookie。

特点:适用于传统Web应用,有状态,不适合跨域或高并发环境

优点:

  • 较易扩展
  • 开发简单

缺点:

  • 需要在服务端存储session,性能低、多服务器同步session困难
  • 由于cookie只在浏览器上能使用,所以它跨平台困难

JWT

原理:服务端通过特定密钥生成包含用户信息的签名字符串(Token),客户端在请求头(Authorization: Bearer Token)中携带。服务器不存储会话,只验证签名。

特点:无状态、扩展性好,适用于微服务和单页应用(SPA)、跨平台能力

JWT 本质上是一个经过数字签名的字符串,它由三部分组成,用点号(.)分隔:Header.Payload.Signature。token长什么样?

Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsb2dpblR5cGUiOiJsb2dpbiIsImxvZ2luSWQiOiIyMDAwNCIsInJuU3RyIjoiQVpVUVY2dnAyUllkOFRucnJoaXNsQWtRcTFhSEFxeTAiLCJ0ZW5hbnRJZCI6IjEiLCJzY29wZSI6IlJPTEU6Ok1JQ1JPX0FQUCIsImFwcEtleSI6InJIWnJZeVB1eW1oaXZlSUUiLCJuaWNrTmFtZSI6IuWui-Wwj-aXrSIsImxvZ2luVGltZSI6MTc3NTYxMzI4NzYxNX0.bq8qEE-imza7pLucKOvKw2WvukW2lSPr1WMbAcuJLmU

优点:

  • 支持跨平台:移动端、跨应用
  • 安全、承载信息丰富

缺点:

  • 刷新与过期处理
  • payload不易过大
  • 中间人攻击(没有绝对的安全)

Oauth

第三方授权,例如 微信、支付宝、QQ、谷歌账号登录等

优点:

  • 开放、安全、简单
  • 权限指定

缺点:

  • 需要增加授权服务器
  • 增加网络请求

二、实战

首先 创建身份验证模块,在指定的目录下执行如下命令:

nest g res auth

整个流程我把它分成两部分:

  1. 用户登录过程
  2. 登录成功后,带token请求业务接口的过程

2.1 前置知识

此段内容篇理论介绍,可以先看后面的代码实现,有疑问再来看这里的内容

模块之间的复用

auth模块中如何使用 users 模块中的 users.service.ts 中的方法

之前的章中我们创建了 Users 模块,现在 auth 模块中想要使用 users.service 中的方法:

  • 首先需要在 users.module.ts 中导出 users.service.ts

    // ... 省略
    @Module({
      imports: [TypeOrmModule.forFeature([UserEntity])],
      controllers: [UsersController],
      providers: [UsersService],
      exports: [UsersService], // 导出
    })
    export class UsersModule {}
    
    
  • auth.module.ts 中导入

    import { Module } from '@nestjs/common';
    import { AuthService } from './auth.service';
    import { AuthController } from './auth.controller';
    import { UsersModule } from '../users/users.module';
    
    @Module({
      imports: [UsersModule],
      controllers: [AuthController],
      providers: [AuthService],
    })
    export class AuthModule {}
    
  • 这样就能在 auth.service.ts 中使用了

    import { Injectable } from '@nestjs/common';
    // 注意这里在使用中还是需要 显示的引入,而不是会自动引入
    import { UsersService } from '../users/users.service';
    
    @Injectable()
    export class AuthService {
      constructor(private readonly usersService: UsersService) {}
    
      async register(createUserDto: any) {
        return this.usersService.findAll();
      }
    }
    

JWT 相关包介绍

首先安装依赖:

 pnpm add @nestjs/passport passport passport-jwt @nestjs/jwt 
pnpm add @types/passport @types/passport-jwt -D

在集成 JWT 之前,先学习一下使用到的包,并了解它都提供了哪些功能。这里是掌握整个流程非常重要的环境,参考了网上(包括官网)一上来就是介绍怎么使用、代码怎么写,确实是让人很迷惑。

1. @nestjs/passport
  1. 集成 Passport.js:让 NestJS 应用能够使用 Passport.js 提供的超过 500 种认证策略(如本地用户名密码、JWT、OAuth 2.0 等)。
  2. 提供装饰器:它提供了一系列装饰器(如 @UseGuards(AuthGuard('jwt'))),让你可以非常方便地在控制器(Controller)或路由上应用认证保护。
  3. 简化策略创建:通过 PassportStrategy 类,你可以轻松地创建自定义策略,而无需直接处理 Passport.js 的底层 API。
import { PassportModule, PassportStrategy, AuthGuard } from '@nestjs/passport';
PassportModule
  • 类型: NestJS 模块 (@Module)

  • 作用: 它是整个 Passport 集成的入口。你需要把它导入到你的 NestJS 模块(如 AuthModule)中,才能启用 Passport 功能。

  • 核心功能

    • 它通过 .register() 方法允许你配置全局选项,比如指定默认的认证策略(defaultStrategy)。
    • 它负责将 Passport 的服务注入到 NestJS 的依赖注入容器中
PassportStrategy-策略实现
  • 类型:抽象类 / 辅助函数

  • 作用:它是用来创建具体认证逻辑的基类。Passport.js 本身有各种策略(如 Local, JWT, Google),这个函数帮助我们将这些策略适配到 NestJS 的类结构中。

  • 核心功能

    • 它接受一个具体的策略类(如 passport-localStrategy)作为参数。
    • 它让你重写 validate 方法,在这里编写具体的验证逻辑(比如查数据库比对密码)。
AuthGuard-路由保护
  • 类型:守卫 (CanActivate)

  • 作用:它是 NestJS 的守卫,用于保护路由。它拦截请求,并告诉 Passport 执行哪个策略。

  • 核心功能

    • 它通常配合 @UseGuards() 装饰器使用。
    • 它接收一个参数(字符串),这个字符串必须与你定义的策略名称(或默认名称)匹配,从而触发对应的验证流程。
2. @nestjs/jwt

@nestjs/jwt 是 Nest 官方对 JWT 的封装包,底层基于 jsonwebtoken。它主要解决三件事:

  1. 在 Nest 里统一配置 JWT 密钥、过期时间等(模块化)
  2. 提供 JwtService 来签发和校验 token
  3. 很容易和 Passportjwt strategy 集成成认证体系
import { JwtModule, JwtService } from '@nestjs/jwt';
JwtModule:模块注册器
  • 用来在 Nest DI 容器里注册 JWT 相关配置和服务

  • 常见用法:

    • JwtModule.register({...}) 静态配置
    • JwtModule.registerAsync({...}) 动态读取配置(比如从 ConfigServiceJWT_SECRET
JwtService:具体干活的服务

用它生成 token、验证 token、解码 token,一般在 AuthService 里注入并调用

  • sign(payload, options?)

    • 作用:根据 payload 生成 JWT 字符串
    • 示例:this.jwtService.sign({ sub: user.id, name: user.name })
    • options 可以覆盖模块默认配置,比如 expiresIn, secret
  • verify(token, options?)

    • 作用:验证 token 是否合法、是否过期,并返回解码后的 payload
    • 验证失败会抛异常(如签名不对、过期)
    • 示例:this.jwtService.verify(token)
3. passport-jwt

passport-jwt 这个包的核心作用是:给 Passport 提供“JWT 认证策略”。

import { ExtractJwt, Strategy } from 'passport-jwt';

主要用到两个能力:

  • Strategy

    • JWT 的 Passport 策略类
    • 你在 Nest 里通常写 extends PassportStrategy(Strategy, 'jwt')
    • 用来定义:用什么密钥验签、是否忽略过期、验证成功后返回什么用户信息
  • ExtractJwt

    • token 提取器工具
    • 最常用:ExtractJwt.fromAuthHeaderAsBearerToken()
    • 表示从 Authorization: Bearer <token> 里取 token
    • 也支持从 cookie、query、或自定义函数提取

简化理解:

  • @nestjs/jwt 偏向“签发/校验 token 的服务能力”
  • passport-jwt 偏向“请求进来时怎么从请求里拿 token 并走认证策略”

二者经常一起用:登录时 JwtService.sign() 发 token,请求鉴权时 passport-jwtStrategy 来验。

4. 总结

首先为什么需要安装 passport:

仅安装 @nestjs/passport 是不够的。简单来说,@nestjs/passport 只是一个“适配器”,它的核心作用是将 passport 的功能封装成 NestJS 熟悉的模块和依赖注入形式,但它本身并不包含 passport 的核心逻辑。

你可以这样理解它们的关系:

  • passport: 这是核心的认证库,提供了所有的认证策略和逻辑。
  • @nestjs/passport: 这是一个 NestJS 的官方封装包,它让你能以更符合 NestJS 风格(如使用装饰器、依赖注入)的方式来使用 passport

完整的登录鉴权依赖

要实现一个完整的登录鉴权功能,你通常需要安装以下几个包:

  1. 核心库:

    • passport: 认证的核心。
    • @nestjs/passport: NestJS 的适配器。
  2. 具体策略库 (根据你选择的认证方式):

    • 用户名密码登录: 需要 passport-local
    • JWT 无状态认证: 需要 passport-jwt
  3. 类型定义 (TypeScript 项目需要):

    • @types/passport
    • @types/passport-local (如果使用 local 策略)
    • @types/passport-jwt (如果使用 jwt 策略)

2.2 JWT 集成

auth.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';

import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';
import { securityRegToken, ISecurityConfig } from '~/config';
import { isDev } from '~/global/env';

@Module({
  imports: [
    PassportModule, // 引入 PassportModule 模块
    // 引入 JwtModule 模块,及配置 JwtModule 模块
    JwtModule.registerAsync({
      imports: [ConfigModule], // 引入 ConfigModule 模块
      useFactory: (configService: ConfigService) => {
        const { jwtSecret, jwtExprire } = configService.get<ISecurityConfig>(securityRegToken)
        return {
          secret: jwtSecret, // 设置密钥
          signOptions: { expiresIn: jwtExprire }, // 设置过期时间
          ignoreExpiration: isDev, // 开发环境忽略过期时间
        }
      },
      inject: [ConfigService], // 注入 ConfigService 服务
    }),
    UsersModule,
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
  ],
})
export class AuthModule {}

以上代码 主要是 引入了 PassportModule, JwtModule,并完善了相关配置;

2.3 用户登录过程

  • 用户输入 用户名、密码 等信息

    通过http(加密|明文),传输给后端

  • 后端接受到来自前端 的http 请求

    • 验证账号密码是否一致
    • 生成 token 传递给前端

auth.controller.ts

import { Controller, Post, Body, UseGuards, Req } from '@nestjs/common';

import { AuthService } from './auth.service';
import { LoginDto } from './dto/auth.dto';

@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
  ) {}

  // 登录
  @Post('login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
}

auth.service.ts

import { HttpException, Injectable, HttpStatus } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { isEmpty } from 'lodash';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async login(loginDto: {
    name: string;
    password: string;
  }) {
    const { name, password } = loginDto;
    const user = await this.usersService.findUserByUserName(name);
    if (isEmpty(user)) {
      throw new HttpException('用户不存在', HttpStatus.NOT_FOUND);
    }

    const { password: userPassword } = user;
    if (userPassword !== password) {
      throw new HttpException('密码错误', HttpStatus.BAD_REQUEST);
    }

    // 生成 JWT 签名
    const jwtSign = await this.jwtService.signAsync({
      id: user.id,
      name: user.name,
      pv: 1, // 版本号
    })
    return {
      access_token: jwtSign
    };
  }
}

用户登录过程代码如上,还是比较简单:

  • 获取用户传递过来的参数,校验用户是否存在,密码是否正确
  • 生成JWT签名,返回token給前端

功能主线如此,密码加密、token管理等有待完善

2.4 业务接口token验证过程

由于文章篇幅,放到下一节讲解;

Flutter iOS应用混淆与安全配置详细文档指南

2026年4月13日 12:03

Flutter iOS应用混淆与安全配置文档

概述

本文档详细描述了iOS应用的混淆与安全配置过程。这些配置旨在保护应用代码、API密钥和敏感数据,防止逆向工程和恶意攻击。配置包括 Dart 代码混淆、原生代码混淆、运行时安全检查和数据安全措施。

混淆与安全措施

Dart代码混淆

Flutter提供了内置的代码混淆功能,通过以下参数启用:

--obfuscate --split-debug-info=./symbols
1

这将:

  • 重命名代码中的标识符,使反编译后的代码难以理解
  • 将调试信息分离到单独的文件中,减少发布版本中的可读信息
  • 保留符号信息用于崩溃分析,但不包含在发布版本中

此外,使用像IpaGuard这样的专业混淆工具可以进一步增强应用安全性。IpaGuard是一款强大的iOS IPA文件混淆工具,无需源码即可对代码和资源进行混淆加密,支持Flutter等多种开发平台,有效增加反编译难度。

原生代码混淆与安全

在iOS上,我们通过以下配置增强安全性:

  1. BuildSettings.xcconfig 配置:
// 启用代码混淆和优化
GCC_OPTIMIZATION_LEVEL = s
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
DEAD_CODE_STRIPPING = YES

// 安全设置
ENABLE_STRICT_OBJC_MSGSEND = YES
CLANG_WARN_SUSPICIOUS_MOVE = YES
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES
GCC_NO_COMMON_BLOCKS = YES
STRIP_STYLE = all
STRIP_INSTALLED_PRODUCT = YES
COPY_PHASE_STRIP = YES
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym

// 启用应用传输安全
PRODUCT_SETTINGS_URL_SCHEMES = "$(inherit)"
PRODUCT_SETTINGS_APP_TRANSPORT_SECURITY_ALLOWS_ARBITRARY_LOADS = NO

// 添加其他安全属性
OTHER_LDFLAGS = $(inherited) -Wl,-no_pie
12345678910111213141516171819202122
  1. Xcode构建参数
xcodebuild -workspace Runner.xcworkspace -scheme Runner -configuration Release clean build \
  ENABLE_BITCODE=YES STRIP_INSTALLED_PRODUCT=YES DEPLOYMENT_POSTPROCESSING=YES \
  -sdk iphoneos -allowProvisioningUpdates
123

这些参数确保:

  • 启用Bitcode,允许App Store进一步优化代码
  • 移除不必要的符号和调试信息
  • 进行部署后处理操作,应用额外的优化

运行时安全检查

通过以下Swift代码实现运行时安全检查:

// 检查设备是否已越狱
func isJailbroken() -> Bool {


    #if targetEnvironment(simulator)
    return false
    #else
    // 检查常见的越狱文件路径
    let jailbreakPaths = [
        "/Applications/Cydia.app",
        "/Library/MobileSubstrate/MobileSubstrate.dylib",
        "/bin/bash",
        "/usr/sbin/sshd",
        "/etc/apt",
        "/private/var/lib/apt/",
        "/usr/bin/ssh"
    ]

    for path in jailbreakPaths {


        if FileManager.default.fileExists(atPath: path) {


            return true
        }
    }

    // 检查是否可以写入私有目录
    let stringToWrite = "Jailbreak Test"
    do {


        try stringToWrite.write(toFile: "/private/jailbreak.txt", atomically: true, encoding: .utf8)
        try FileManager.default.removeItem(atPath: "/private/jailbreak.txt")
        return true
    } catch {


        // 无法写入,说明没有越狱
    }

    return false
    #endif
}

// 检查是否连接调试器
func isDebuggerAttached() -> Bool {


    #if DEBUG
    return false
    #else
    var info = kinfo_proc()
    var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
    var size = MemoryLayout<kinfo_proc>.stride
    let status = sysctl(&mib, UInt32(mib.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556

苹果iOS应用开发上架与推广完整教程

2026年4月13日 10:38

苹果应用上架与推广详细指南

作为苹果个人开发者,确保用户能够顺畅地下载并安装自己精心开发的应用,是不可或缺的一环。接下来,我们将深入探讨实现这一目标的一系列核心步骤。

01应用上架准备

> 注册开发者账号

注册开发者账号是发布应用的第一步,必须在苹果开发者官网完成信息填写和费用支付。你需要前往苹果开发者官网,填写包括姓名、联系方式在内的个人真实信息,并支付相应费用。完成注册与身份验证后,你将获得发布应用的权限。

> 确保应用符合准则

在用户能够顺畅地下载并安装应用之前,开发者需要完成一系列的准备工作。这些准备工作的目标是确保应用的下载和安装过程尽可能顺畅,从而提升用户体验。

应用需符合苹果的质量与安全标准,不得包含恶意代码,需具备良好的UI设计和正常功能。苹果对应用的质量、功能及安全性都设有严格标准。应用不得包含恶意代码,必须具备良好的用户界面设计,且功能正常、无显著漏洞。此外,应用还需遵守法律法规,如不得侵犯他人知识产权,不得诱导用户进行不合理付费等。只有满足这些准则的应用,才有可能通过审核并上架供用户下载。

> 选用开发工具与测试

接下来,我们就将详细介绍这些准备工作。在着手构建和测试应用之前,有几个关键步骤需要完成。这些步骤旨在为应用的顺畅下载和安装铺平道路,进而优化用户体验。

使用Xcode进行开发,选择合适的编程语言并在多设备和系统版本上进行全面测试,确保功能完备、兼容性和性能。通常,Xcode被视为开发首选,它提供了从代码编写到编译、调试的一站式服务。依据应用特性,选择如Swift或Objective-C等编程语言。以一个简易笔记应用为例,我们可以运用Swift来构建用户界面,并实现笔记的增删改查功能。务必在不同型号的iOS设备和多种系统版本上进行详尽测试。这包括验证功能的完备性,例如确认笔记应用能否顺利存储和读取数据;确保兼容性,以保证应用在iPhone、iPad等设备上的一致性;以及评估性能,如测试应用的启动速度和响应时间。例如,在iPhone 13与iPhone 8上分别运行笔记应用,观察是否存在布局混乱或功能失效的问题。此外,开发者也可以使用AppUploader等工具来简化iOS证书的申请和管理,支持在Windows、Linux或Mac系统中操作,无需依赖Mac电脑。

02向App Store提交与推广

> 准备元数据与分类选择

接下来,就可以将你的应用提交到App Store了。 提交应用前需准备应用名称、描述、截图及视频,并选择合适的分类以提高用户搜索的精准性

为应用起一个简洁且能体现核心功能的名称,如“速记笔记”,同时撰写详细且吸引人的描述,突出应用特点、功能和优势。此外,还需提供展示关键界面和操作流程的截图及视频预览,使用户能直观了解应用。依此选择合适的分类,如笔记应用可归于“效率”类别。这样能帮助用户在App Store中更精准地搜索到应用。

> 提交审核与推广策略

通过App Store Connect提交审核,积极进行应用推广,包括社交媒体、博主合作及应用内推广,提高用户下载量。

使用Xcode完成应用打包后,通过App Store Connect提交应用进行审核。或者,使用AppUploader工具上传IPA文件到App Store,它支持多平台,比Application Loader更高效,且不携带设备信息。审核过程通常需数日,期间苹果团队将检查应用是否符合各项标准。若审核过程中发现任何问题,将收到反馈通知,需根据反馈进行相应修改后重新提交。同时,积极推广应用也是提高下载量的关键。

  1. 社交媒体推广:借助Twitter、Facebook、Instagram等社交媒体平台,广泛宣传您的应用。您可以分享应用截图、详细的功能介绍以及使用教程等,以此吸引更多潜在用户的关注。例如,在Twitter上发布一系列关于您的笔记应用使用技巧的推文,并附上应用的下载链接。

  2. 与博主和媒体合作:积极联系相关领域的博主、自媒体或科技媒体,向他们介绍您的应用,并努力争取他们的推荐或报道。例如,与专注于效率类应用评测的博主取得联系,邀请他们体验您的应用并分享他们的使用感受。

  3. 应用内推广:如果您还有其他已发布的应用,可以在这些应用内部推广您的新应用,从而引导现有用户下载并体验。

通过上述推广策略,苹果个人开发者可以成功地推动应用的下载安装,并通过广泛的推广活动提高应用的下载量和用户活跃度,从而让您的开发成果得到更多用户的认可和喜爱。

nestjs实战 - 拦截器,统一处理接口请求与响应结果

作者 web_bee
2026年4月13日 10:13

在之前的篇章中介绍了 拦截器的基本概念、使用方法、使用场景;

本节主要从实战层面开发一个通用功能:统一处理接口请求与响应结果

需求:

  • 统一处理接口请求与响应结果
  • 可选配置(部分接口如果不需要统一处理 可配置)

第一步,全局注入拦截器

首先创建一个 transform.interceptor.ts 文件,并全局注入:

/// app.module.ts
// 省略其它代码
// 主要代码
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';

@Module({
  // ...
  providers: [
    // 全局注入拦截器,它会作用到所有路由上
    { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, 
  ],
})
export class AppModule {}

第二步,拦截器功能实现

需要注意的点,我们需要处理

  • 请求参数(前置拦截器)
  • 响应结果(后置拦截器)

在之前的章节中也介绍了这两个概念。

// 首先需要下载两个相关依赖
pnpm add fastify qs  

transform.interceptor.ts

实现拦截器 transform.interceptor.ts 内部逻辑:

import {
  NestInterceptor,
  CallHandler,
  ExecutionContext,
  Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import type { FastifyRequest } from 'fastify'
import qs from 'qs';

import { ResponseModel } from '../mode/response.mode';
import { BYPASS_KEY } from '../decorators/bypass.decorator';


/**
 * 响应拦截器
 * 用于处理响应数据
 * 可以用于处理响应数据,如添加响应头,添加响应体等
 */
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<ResponseModel<T>> {
    // ==========================
    // 【阶段 1:控制器执行之前】
    // ==========================
    // 这里的代码会立即同步执行。
    // 此时请求刚到达拦截器,还没进控制器。

    // ✅功能1:获取是否需要跳过拦截器
    const bypass = this.reflector.get<boolean>(
      BYPASS_KEY,
      context.getHandler(),
    )

    // 如果在这里直接 return 一个 Observable (例如 return of({error: 'blocked'}))
    // 而不调用 next.handle(),控制器将永远不会执行(短路)。
    // 调用 next.handle() 启动控制器逻辑
    // 它返回一个 Observable,代表控制器未来的执行结果(流)
    if (bypass)
      return next.handle()
    
    // ✅功能2:获取请求对象
    const http = context.switchToHttp()
    const request = http.getRequest<FastifyRequest>()
    // 处理 query 参数,将数组参数转换为数组,如:?a[]=1&a[]=2 => { a: [1, 2] }
    request.query = qs.parse(request.url.split('?').at(1))


    // ✅功能3:调用控制器逻辑
    const response$ = next.handle(); 
    // 【阶段 2:控制器执行之后】
    // ==========================
    // 这里的代码不会立即执行!
    // 它们被注册为 RxJS 的“操作符”,只有当控制器执行完毕并产生数据时,流才会流动到这里。
    return response$.pipe(
      map((data) => {
        console.log('data', data);
        return ResponseModel.success(data);
      }),
    );
  }
}

response.mode.ts

它是生成 响应数据 的一个构造函数;

import { HttpStatus } from "@nestjs/common";

export class ResponseModel<T = any> {
  code: number;
  message: string;
  data?: T;

  constructor(code: number, message: string, data?: T) {
    this.code = code;
    this.message = message;
    this.data = data ?? undefined;
  }

  static success<T>(data?: T) {
    return new ResponseModel(HttpStatus.OK, 'success', data);
  }

  static error(code: number, message: string) {
    return new ResponseModel(code, message, null);
  }
}

bypass.decorator.ts

配置:是否使用 - 拦截器统一响应数据结构功能,亦可解释为 此拦截器功能的开关

import { SetMetadata } from '@nestjs/common';

export const BYPASS_KEY = '__bypass_key__';

/**
 * 当不需要转换成基础返回格式时添加该装饰器
 */
export const Bypass = () => SetMetadata(BYPASS_KEY, true);

SetMetadata

在 NestJS 中,@SetMetadata() 是一个核心装饰器,用于向路由处理器(Controller 中的方法)或控制器类附加自定义的元数据(Metadata)。简单来说,它允许你给代码“打标签”,这些标签可以在运行时被读取,从而实现灵活、声明式的逻辑控制,例如权限校验、日志记录或缓存策略等。

1. 设置元数据

你可以直接在控制器或其方法上使用 @SetMetadata('key', value) 来设置元数据。

  • key: 一个字符串,作为元数据的唯一标识。
  • value: 任意类型的值,是你想要存储的数据。

示例:

import { Controller, Get, SetMetadata } from '@nestjs/common';

@Controller('cats')
export class CatsController {

  // 为单个方法设置元数据
  @Get()
  @SetMetadata('roles', ['admin']) // key 是 'roles', value 是 ['admin']
  findAll() {
    return 'This action returns all cats';
  }

  // 也可以为整个控制器设置元数据
  @SetMetadata('isPublic', true)
  @Get('public')
  findPublic() {
    return 'This is a public route';
  }
}

设置元数据的最佳实践,如上文中我们的写法,通过一个自定义装饰,为控制器 或 方法 设置。

2. 获取元数据

设置的元数据本身是静态的,它的价值在于在运行时被动态读取。这通常在 守卫(Guards)拦截器(Interceptors)管道(Pipes) 中完成,通过注入 Reflector 辅助类来实现。

Reflector 提供了多种方法来读取元数据,最常用的是 get()

以上文中拦截器为例,获取元数据:

// ...
import KEY_NAME from '../****'

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor {
  constructor(private readonly reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<ResponseModel<T>> {
    const bypass = this.reflector.get<boolean>(
      KEY_NAME,
      context.getHandler(), // 获取控制器方法的元数据
    )
  }
}

第三步,使用

测试一下,我们再users.controller.ts 中测试使用

import { Bypass } from '~/common/decorators/bypass.decorator';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  @Bypass() // 过滤全局统一响应数据拦截器
  findAll() {
    return this.usersService.findAll();
  }
}

注意:

const bypass = this.reflector.get<boolean>(
  BYPASS_KEY,
  context.getHandler(), // 获取控制器方法的元数据
)

@Bypass 装饰器只能作用在 路由处理方法 上。

总结:

以上就完成了 统一处理接口请求与响应结果 功能的开发,顺便让我们熟悉了 拦截器的用法;

再次回顾一下拦截器的使用场景:

  • 请求参数统一处理:格式转换
  • 响应数据统一 格式化
  • 响应缓存
  • 超时处理
  • 数据序列化/脱敏

业务系统深度集成:基于OnlyOffice中国版连接器实现合同生成、AI写作与报表自动化

作者 胖纳特
2026年4月13日 10:07

业务系统深度集成:基于OnlyOffice中国版连接器实现合同生成、AI写作与报表自动化

一、为什么需要连接器

在大多数企业系统中,文档编辑器只是一个"嵌入式组件"——用户打开、编辑、保存,仅此而已。但真实业务场景中,我们往往需要从外部系统控制文档内容:

  • 合同系统需要将业务数据自动填入合同模板
  • AI写作系统需要将生成的内容插入到光标位置
  • 报表系统需要将统计数据写入Excel并自动生成图表
  • 审批系统需要在文档中自动插入审批意见和签章

这些需求的共同特点是:操作文档的主体不是用户,而是外部系统

OnlyOffice 提供了 连接器(Connector) 机制来满足这类需求。中国版完整实现了官方连接器的全部功能,兼容官方 JSAPI,并可与用户只读模式动态权限切换等增强功能配合使用,构建出更强大的业务集成方案。

中国版连接器增强能力

  • 兼容官方 Automation API,支持 Word/Excel/PPT 全文档类型操作
  • 可与用户只读模式配合:用户无法手动编辑,但连接器可操作文档
  • 支持动态权限切换:运行时通过连接器修改用户权限
  • 支持细粒度文档操作:段落、Run、样式、图表等均可操控

二、连接器基础

2.1 什么是连接器

连接器是 OnlyOffice 文档编辑器提供的 JavaScript API 接口,允许外部代码(宿主页面)对正在编辑的文档执行操作。它与插件(Plugin)拥有相同的底层接口,但使用方式更灵活:

  • 插件:需要打包部署到 documentserver 内部,通过编辑器内的插件菜单激活
  • 连接器:直接在宿主页面的 JavaScript 中调用,无需部署任何文件

对于业务系统集成来说,连接器是更合适的选择

2.2 创建连接器

在初始化编辑器后,通过 createConnector 方法获取连接器实例:

// 初始化编辑器
const docEditor = new DocsAPI.DocEditor("placeholder", config);

// 创建连接器
const connector = docEditor.createConnector();

2.3 核心方法

连接器提供两个核心方法:

callCommand —— 在文档上下文中执行代码:

connector.callCommand(function () {
    // 这里的代码运行在文档编辑器内部
    // 可以使用 Api 对象操作文档
    var oDocument = Api.GetDocument();
    // ...
});

executeMethod —— 调用编辑器提供的方法:

connector.executeMethod("InsertTextToCursor", ["Hello World"]);

两者的区别在于:callCommand 内的函数运行在编辑器沙箱中,可以调用完整的文档操作 API;executeMethod 是对常用操作的封装,调用更简洁。

注意callCommand 中的函数是序列化后传递到编辑器内部执行的,因此不能引用外部变量。需要传递数据时,可以通过函数返回值或事件机制。

三、场景一:合同模板自动填充

3.1 业务需求

某企业合同管理系统的需求:

  • 合同使用标准 Word 模板,包含固定条款和可变字段
  • 业务人员在系统中填写合同要素(甲乙方、金额、期限等)
  • 系统自动将数据填入模板对应位置
  • 用户在编辑器中只能查看结果,不能手动编辑

3.2 模板设计

在 Word 模板中,使用特定格式的占位符标记可变内容,例如:

甲方:{{partyA}}
乙方:{{partyB}}
合同金额:人民币 {{amount}} 元整
合同期限:{{startDate}} 至 {{endDate}}

3.3 技术实现

第一步:配置编辑器

使用用户只读模式,确保用户不能手动编辑,但连接器可以操作文档:

const config = {
  document: {
    fileType: "docx",
    key: contractKey,
    title: "采购合同-2026-0412",
    url: templateDownloadUrl,
    permissions: {
      edit: true,
      copy: true,
      copyOut: false,
      print: true
    }
  },
  editorConfig: {
    mode: "edit",
    customization: {
      readOnly: true,  // 用户只读模式
      waterMark: {
        value: `${currentUser.name}\\n合同预览`,
        fillstyle: "rgba(192, 192, 192, 0.2)",
        font: "14px SimHei",
        rotate: -30,
        opacity: 0.2
      }
    }
  }
};

第二步:获取业务数据并填充

当用户在业务表单中填写完合同要素后,通过连接器将数据写入文档:

// 业务数据
const contractData = {
  partyA: "北京某某科技有限公司",
  partyB: "上海某某信息技术有限公司",
  amount: "壹佰贰拾叁万肆仟伍佰陆拾柒",
  amountNum: "1,234,567.00",
  startDate: "2026年04月12日",
  endDate: "2027年04月11日",
  signDate: "2026年04月12日"
};

// 通过连接器填充数据
function fillContract(data) {
  const connector = docEditor.createConnector();

  // 将数据序列化后传入
  const jsonData = JSON.stringify(data);

  connector.callCommand(function () {
    // 在文档上下文中执行
    var oDocument = Api.GetDocument();
    var aElements = oDocument.GetAllContentControls();

    // 如果使用内容控件方式
    for (var i = 0; i < aElements.length; i++) {
      var tag = aElements[i].GetTag();
      // 根据 tag 匹配字段并替换
    }
  });

  // 也可以使用搜索替换方式
  connector.callCommand(function () {
    var oDocument = Api.GetDocument();

    // 使用 SearchAndReplace 方法
    var oSearchData = {
      searchString: "{{partyA}}",
      replaceString: "北京某某科技有限公司",
      matchCase: true
    };

    oDocument.SearchAndReplace(oSearchData);
  });
}

第三步:逐字段替换的完整实现

实际项目中,建议封装一个通用的模板填充方法:

function fillTemplate(connector, fieldMap) {
  const entries = Object.entries(fieldMap);

  // 由于 callCommand 内部不能引用外部变量
  // 需要逐个字段调用,或者将数据编码到函数体中
  entries.forEach(([placeholder, value]) => {
    // 动态构造函数字符串
    const script = `
      var oDocument = Api.GetDocument();
      oDocument.SearchAndReplace({
        searchString: "{{${placeholder}}}",
        replaceString: "${value.replace(/"/g, '\\"')}",
        matchCase: true
      });
    `;

    connector.callCommand(new Function(script));
  });
}

// 使用
fillTemplate(connector, {
  partyA: contractData.partyA,
  partyB: contractData.partyB,
  amount: contractData.amount,
  amountNum: contractData.amountNum,
  startDate: contractData.startDate,
  endDate: contractData.endDate
});

3.4 用户只读模式详解

用户只读模式是中国版特有的功能,可以实现"用户不可编辑,但连接器可操作文档"的效果。

与普通只读模式的区别

模式 用户能否编辑 连接器能否操作 适用场景
普通只读(mode: view) 纯预览场景
用户只读(readOnly: true) 合同生成、公文套打等

配置要点

{
  "editorConfig": {
    "customization": {
      "readOnly": true
    },
    "permissions": {
      "edit": true
    },
    "mode": "edit"
  }
}

三个字段必须同时配置:mode 设为 editpermissions.edit 设为 truecustomization.readOnly 设为 true

注意:用户只读模式为高级版功能,目前仅支持 Word/Excel/PPT 的 PC 模式

3.5 关键注意事项

  • 模板占位符应使用不易与正文冲突的格式(如 {{fieldName}}
  • 替换操作完成后,建议调用保存接口生成最终文档
  • 用户只读模式保证了模板结构和法律条款不会被手动修改
  • 结合防截图水印,可以在合同预览阶段保护内容安全

四、场景二:AI辅助写作集成

4.1 业务需求

某内容管理平台需要集成 AI 写作能力:

  • 用户在文档中编辑时,可以通过侧边栏调用 AI 功能
  • AI 生成的内容可以插入到当前光标位置
  • 支持 AI 润色:选中文本 → 调用 AI 改写 → 替换原文
  • 支持 AI 续写:在光标位置根据上下文续写内容

4.2 架构设计

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   业务前端    │────→│  AI 服务端    │────→│  大语言模型   │
│  (侧边栏)    │←────│  (API网关)    │←────│  (LLM)       │
└──────┬───────┘     └──────────────┘     └──────────────┘
       │
       │ connector.callCommand()
       ↓
┌──────────────┐
│  OnlyOffice  │
│  编辑器      │
└──────────────┘

4.3 核心实现

获取选中文本,发送给AI处理

// 获取当前选中的文本
function getSelectedText(connector) {
  return new Promise((resolve) => {
    connector.callCommand(
      function () {
        var oDocument = Api.GetDocument();
        var selectedText = oDocument.GetSelectedText();
        return selectedText;
      },
      false, // isNoCalc
      function (result) {
        resolve(result);
      }
    );
  });
}

// AI润色流程
async function aiPolish() {
  const selectedText = await getSelectedText(connector);

  if (!selectedText) {
    alert("请先选中需要润色的文本");
    return;
  }

  // 调用后端AI接口
  const response = await fetch("/api/ai/polish", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: selectedText })
  });

  const { result } = await response.json();

  // 将AI结果替换选中内容
  connector.callCommand(function () {
    var oDocument = Api.GetDocument();
    // 在当前选区位置插入新文本
    var oParagraph = Api.CreateParagraph();
    oParagraph.AddText(result);
    oDocument.InsertContent([oParagraph], true); // true 表示替换选区
  });
}

在光标位置插入AI生成的内容

async function aiGenerate(prompt) {
  const response = await fetch("/api/ai/generate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ prompt })
  });

  const { result } = await response.json();

  // 将生成的内容插入光标位置
  connector.callCommand(function () {
    var oParagraph = Api.CreateParagraph();
    var oRun = Api.CreateRun();

    // 设置字体样式与文档保持一致
    oRun.AddText(result);
    oRun.SetFontFamily("SimSun");
    oRun.SetFontSize(24); // 单位是半磅,24 = 12pt

    oParagraph.AddElement(oRun);

    var oDocument = Api.GetDocument();
    oDocument.InsertContent([oParagraph]);
  });
}

4.4 流式输出的处理

如果AI接口支持流式输出(SSE),可以实现逐字显示效果。但需要注意,频繁调用 callCommand 会有性能开销。建议的处理方式:

  • 在侧边栏先完成AI内容的流式展示
  • 用户确认后一次性插入到文档中
  • 或者每积累一定长度(如一个段落)后批量插入
// 推荐:在侧边栏展示完整结果后,一次性插入
function insertAiResult(text) {
  const paragraphs = text.split("\n").filter(p => p.trim());

  connector.callCommand(function () {
    var aContent = [];

    for (var i = 0; i < paragraphs.length; i++) {
      var oParagraph = Api.CreateParagraph();
      oParagraph.AddText(paragraphs[i]);
      aContent.push(oParagraph);
    }

    var oDocument = Api.GetDocument();
    oDocument.InsertContent(aContent);
  });
}

五、场景三:Excel报表自动生成

5.1 业务需求

某数据分析平台需要将统计数据自动填入Excel模板并生成图表:

  • 每月自动生成销售报表
  • 将数据库中的统计数据写入对应的单元格
  • 根据数据自动更新图表
  • 生成后的报表可以供用户在线查看和下载

5.2 写入表格数据

// 销售数据
const salesData = [
  { month: "1月", revenue: 125000, cost: 89000, profit: 36000 },
  { month: "2月", revenue: 138000, cost: 92000, profit: 46000 },
  { month: "3月", revenue: 156000, cost: 98000, profit: 58000 },
  // ...
];

function fillExcelReport(connector, data) {
  // 将数据转为JSON字符串,嵌入到函数中
  const jsonStr = JSON.stringify(data);

  connector.callCommand(function () {
    var data = JSON.parse(jsonStr);
    var oWorksheet = Api.GetActiveSheet();

    // 写入表头
    oWorksheet.GetRange("A1").SetValue("月份");
    oWorksheet.GetRange("B1").SetValue("收入(元)");
    oWorksheet.GetRange("C1").SetValue("成本(元)");
    oWorksheet.GetRange("D1").SetValue("利润(元)");

    // 设置表头样式
    var headerRange = oWorksheet.GetRange("A1:D1");
    headerRange.SetBold(true);
    headerRange.SetFillColor(Api.CreateColorFromRGB(68, 114, 196));
    headerRange.SetFontColor(Api.CreateColorFromRGB(255, 255, 255));

    // 写入数据
    for (var i = 0; i < data.length; i++) {
      var row = i + 2;
      oWorksheet.GetRange("A" + row).SetValue(data[i].month);
      oWorksheet.GetRange("B" + row).SetValue(data[i].revenue);
      oWorksheet.GetRange("C" + row).SetValue(data[i].cost);
      oWorksheet.GetRange("D" + row).SetValue(data[i].profit);
    }

    // 设置数字格式
    var dataRows = data.length;
    oWorksheet.GetRange("B2:D" + (dataRows + 1)).SetNumberFormat("#,##0.00");

    // 自动调整列宽
    oWorksheet.GetRange("A1:D1").SetColumnWidth(15);
  });
}

5.3 自动创建图表

function createChart(connector, dataRowCount) {
  connector.callCommand(function () {
    var oWorksheet = Api.GetActiveSheet();

    // 创建柱状图
    var oChart = oWorksheet.AddChart(
      "'" + oWorksheet.GetName() + "'!$A$1:$D$" + (dataRowCount + 1),
      true,  // 按行
      "bar", // 图表类型
      2,     // 样式
      200 * 36000,   // 宽度(EMU)
      150 * 36000    // 高度(EMU)
    );

    oChart.SetTitle("月度销售报表", 12);
    oChart.SetLegendPos("bottom");

    // 将图表放置在数据下方
    oChart.SetPosition(oWorksheet, dataRowCount + 3, 0, 0, 0);
  });
}

5.4 完整工作流

async function generateMonthlyReport() {
  // 1. 从后端获取数据
  const response = await fetch("/api/reports/monthly-sales");
  const salesData = await response.json();

  // 2. 创建连接器
  const connector = docEditor.createConnector();

  // 3. 填充数据
  fillExcelReport(connector, salesData);

  // 4. 生成图表
  createChart(connector, salesData.length);

  // 5. 通知用户
  showNotification("报表生成完成");
}

六、连接器开发的最佳实践

6.1 数据传递

由于 callCommand 中的函数在编辑器沙箱中执行,不能直接引用外部变量。推荐的数据传递方式:

// 方式一:将数据序列化后拼接到函数体中
function setValueByConnector(connector, cellRef, value) {
  const safeValue = JSON.stringify(value);
  connector.callCommand(
    new Function(`
      var oSheet = Api.GetActiveSheet();
      oSheet.GetRange("${cellRef}").SetValue(${safeValue});
    `)
  );
}

// 方式二:使用 callCommand 的回调获取返回值
connector.callCommand(
  function () {
    return Api.GetDocument().GetStatistics();
  },
  false,
  function (stats) {
    console.log("文档统计:", stats);
  }
);

6.2 错误处理

function safeCallCommand(connector, fn, callback) {
  try {
    connector.callCommand(fn, false, function (result) {
      if (callback) callback(null, result);
    });
  } catch (error) {
    console.error("连接器调用失败:", error);
    if (callback) callback(error, null);
  }
}

6.3 性能优化

  • 批量操作:将多个操作合并到一次 callCommand 调用中,减少通信开销
  • 避免频繁调用:不要在循环中逐次调用 callCommand,应在单次调用中完成所有操作
  • 异步处理callCommand 是异步的,注意操作顺序的控制
// 不推荐:逐行调用
for (let i = 0; i < 1000; i++) {
  connector.callCommand(function () {
    // 写入一行数据
  });
}

// 推荐:一次性写入所有数据
connector.callCommand(function () {
  var oSheet = Api.GetActiveSheet();
  for (var i = 0; i < 1000; i++) {
    oSheet.GetRange("A" + (i + 1)).SetValue("data" + i);
  }
});

6.4 动态权限切换(中国版特有)

中国版自 9.3.0 版本开始支持通过连接器动态修改用户权限,无需重新打开文档即可实时生效。

使用场景

  • 审批流程中,审批人点击"开始审批"后自动切换为只读模式
  • 文档状态变化时,动态调整用户的编辑/复制/打印权限
  • 根据业务规则,在特定条件下限制用户操作

实现示例

// 创建连接器
const connector = docEditor.createConnector();

// 审批人点击"开始审批"按钮时,切换为只读+可评论
function onStartReview() {
  connector.callCommand(function () {
    Api.changePermissions({
      edit: false,
      comment: true,
      copy: true,
      copyOut: false,
      print: false
    });
  });
}

// 审批通过后,进入签署阶段,完全禁止操作
function onApproved() {
  connector.callCommand(function () {
    Api.changePermissions({
      edit: false,
      comment: false,
      copy: false,
      copyOut: false,
      print: false
    });
  });
}

// 审批驳回,退回给起草人编辑
function onRejected() {
  connector.callCommand(function () {
    Api.changePermissions({
      edit: true,
      comment: true,
      copy: true,
      copyOut: true,
      print: true
    });
  });
}

支持的权限字段

字段 说明 类型
comment 是否允许评论 Boolean
copy 是否允许复制 Boolean
copyOut 是否允许复制到外部(中国版特有) Boolean
edit 是否允许编辑 Boolean
print 是否允许打印 Boolean

注意:动态权限切换为高级版功能,目前仅支持 Word/Excel/PPT 的 PC 模式

6.5 与中国版增强功能的配合

连接器可以与中国版的多个增强功能组合使用,构建更强大的业务场景:

组合方式 典型场景 关键配置
连接器 + 用户只读模式 合同制作、公文套打 customization.readOnly: true
连接器 + 动态权限切换 审批流程中的权限流转 Api.changePermissions()
连接器 + 防截图水印 安全环境下的自动文档生成 customization.waterMark
连接器 + 内部剪切板 敏感数据填充后防止用户复制到外部 permissions.copyOut: false
连接器 + 迷你工具栏 简化用户编辑体验 customization.miniToolbar: true

七、与WPS JSSDK的对比

对于有国内办公套件集成经验的开发者,可能更熟悉 WPS 的 JSSDK。以下是两者的关键差异:

对比维度 OnlyOffice 连接器 WPS JSSDK
API丰富度 与插件接口相同,覆盖面广 提供标准化接口,覆盖常用场景
文档操作深度 可操作到段落、Run、样式等细粒度 以高层封装为主
私有化部署 完全支持 需要商业授权
学习成本 需了解 OOXML 模型 接口设计更面向业务
扩展性 插件 + 连接器双通道 SDK标准接口

OnlyOffice 连接器的优势在于更深的文档操作能力和完全的私有化支持,适合需要深度定制的企业级场景。

八、总结

OnlyOffice 中国版的连接器为业务系统与文档编辑器之间架起了一座桥梁。通过 JSAPI,外部系统可以像操作数据库一样操作文档内容——读取、写入、格式化、生成图表,一切都可以通过代码完成。

核心价值:

  • 合同生成:模板 + 数据 = 标准合同,告别手工填写
  • AI写作:大模型生成的内容无缝融入文档编辑流程
  • 报表自动化:数据驱动的文档生成,取代重复的手工操作
  • 流程驱动:文档操作与业务流程深度绑定,实现真正的自动化

连接器让 OnlyOffice 不再只是一个编辑器,而是业务系统中可编程的文档引擎。

相关资源

BaseMetas Fileview 在线文件预览服务部署对接指南

作者 胖纳特
2026年4月13日 09:33

本文面向需要将文件预览能力集成到自有系统的开发人员,覆盖从 Docker 部署、反向代理配置、API 对接到生产环境最佳实践的完整流程。读完本文,你将能够在自己的业务系统中完成 Fileview 的部署与集成。


一、了解 Fileview

1.1 它是什么

BaseMetas Fileview 是一款通用型在线文档预览引擎,支持超过 200 种文件格式的在线预览,覆盖 Office 文档、PDF、OFD、CAD 图纸、3D 模型、代码文件、流程图、思维导图、压缩包、音视频、图片等全格式类型。

它的设计目标是:作为独立的预览服务,通过标准 HTTP 接口集成到任意业务系统中。你的系统只需要把"文件地址"告诉 Fileview,它负责下载、转换、渲染,最终在浏览器中呈现预览结果。

1.2 架构概览

Fileview 内部由两个服务组成,Docker 镜像已将它们打包在一起,对外只暴露一个 HTTP 端口:

┌─────────────────────────────────────────┐
│           Docker 容器 (端口 80)           │
│                                         │
│  ┌─────────────┐    ┌─────────────────┐ │
│  │  预览服务     │───▶│   转换服务       │ │
│  │ (HTTP API)  │    │ (格式转换引擎)    │ │
│  └──────┬──────┘    └────────┬────────┘ │
│         │                    │          │
│     ┌───┴────────────────────┴───┐      │
│     │   RocketMQ + Redis + 存储   │      │
│     └────────────────────────────┘      │
└─────────────────────────────────────────┘
  • 预览服务:对外提供 /preview/api/** 等 HTTP 接口,负责请求编排、文件下载、权限校验、结果缓存、长轮询响应
  • 转换服务:内部服务,通过订阅 MQ 事件被动触发,负责文件格式转换(Office → PDF/HTML、CAD → SVG 等)
  • Redis:统一的状态与缓存中心,存储下载/转换任务状态、缓存预览结果
  • RocketMQ:事件总线,承载预览相关事件,负责下载任务和转换任务的异步投递

作为集成方,你不需要关心内部实现,只需要:部署 → 配置代理 → 调用 API

1.3 集成交互流程

你的业务系统                        Fileview 预览服务
    │                                    │
    │  1. 用户点击文件                      │
    │──────────────────────────────────▶ │
    │  2. 构造预览URL                      │
    │     (包含文件下载地址)                  │
    │  3. 浏览器打开预览URL                  │
    │  ─────────────────────────────────▶│
    │                                    │ 4. 下载文件
    │                                    │ 5. 格式转换
    │                                    │ 6. 返回渲染结果
    │  ◀─────────────────────────────────│
    │  7. 用户看到预览效果                    │

二、环境准备

2.1 硬件要求

规格 最低配置 推荐配置 高并发场景
CPU 2 核 4 核 8 核+
内存 2GB 4GB 8GB+
磁盘 10GB 50GB 100GB+(取决于文件量)

磁盘空间主要用于存储下载的源文件和转换后的结果文件。Fileview 内置临时文件清理机制,会定期清理过期文件。

2.2 软件要求

  • Docker:20.10+(必需)
  • Nginx:生产环境推荐使用反向代理(非必需,但强烈建议)

2.3 支持的 CPU 架构

  • AMD64 (x86_64)
  • ARM64 (aarch64)

三、部署

3.1 拉取镜像

# Docker Hub(首选)
docker pull basemetas/fileview:latest

# 国内镜像加速
docker pull docker.1ms.run/basemetas/fileview:latest
# 或
docker pull dockerproxy.net/basemetas/fileview:latest

3.2 启动服务

最简启动(开发/测试):

docker run -itd \
    --name fileview \
    -p 9000:80 \
    --restart=always \
    basemetas/fileview:latest

容器内部监听 80 端口,映射到宿主机 9000 端口。

挂载数据目录(生产推荐):

docker run -itd \
    --name fileview \
    -p 9000:80 \
    -v /data/fileview/storage:/opt/fileview/data \
    -v /data/fileview/logs:/opt/fileview/logs \
    --restart=always \
    basemetas/fileview:latest

这样可以将文件存储和日志持久化到宿主机,避免容器重建后数据丢失。

3.3 验证部署

浏览器访问 http://<你的服务器IP>:9000/,如果看到 Fileview 欢迎页,说明部署成功。

也可以通过命令行验证:

curl -I http://localhost:9000/preview/welcome
# 应该返回 HTTP 200

四、反向代理配置(生产环境必做)

生产环境中不建议直接暴露 Docker 端口,应通过 Nginx 反向代理统一管理。以下提供两种典型部署模式。

4.1 独立域名部署

Fileview 使用一个独立的域名或子域名:

server {
    listen 443 ssl;
    server_name preview.example.com;

    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:9000;

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Forwarded-Host $host;
    }
}

此时预览服务的 Base URL 为:https://preview.example.com

4.2 子路径部署(与业务系统同域)

Fileview 部署在业务系统的一个子路径下(如 /fileview/),这种方式可以避免跨域问题:

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # 你的业务系统
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Fileview 预览服务
    location /fileview/ {
        proxy_pass http://127.0.0.1:9000/;

        # 关键:告知 Fileview 自己处于子路径下
        proxy_set_header X-Forwarded-Prefix /fileview;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Forwarded-Host $host;
    }
}

X-Forwarded-Prefix 是子路径部署的关键请求头。Fileview 会根据它自动调整生成的预览 URL 前缀,确保返回给浏览器的地址是正确可访问的。

此时预览服务的 Base URL 为:https://app.example.com/fileview

4.3 代理头说明

请求头 作用 是否必需
X-Forwarded-Proto 告知原始协议(http/https) 是(HTTPS 场景)
X-Forwarded-Host 告知原始访问域名 推荐
X-Forwarded-Prefix 告知子路径前缀 子路径部署时必需
X-Forwarded-Port 告知原始访问端口 推荐
REMOTE-HOST 告知原始客户端 IP 推荐
Host 标准 Host 头

Fileview 会按优先级解析这些请求头,动态生成预览 URL 的 Base URL。详细机制参见预览地址生成机制


五、API 集成

Fileview 的集成核心只有一件事:构造正确的预览 URL

5.1 预览 URL 格式

{baseUrl}/preview/view?url={fileUrl}&fileName={fileName}

其中:

  • {baseUrl}:Fileview 服务的访问地址
  • {fileUrl}:文件的网络下载地址(需 URL 编码)
  • {fileName}:文件名(需 URL 编码,用于文件类型判断)

5.2 预览网络文件(最常用)

方式一:query 参数(简单直接)

// 你的系统中,生成文件的下载链接
const fileDownloadUrl = "https://app.example.com/api/files/download?id=12345";
const fileName = "年度报告.docx";

// 构造预览 URL
const previewUrl = `https://app.example.com/fileview/preview/view?url=${encodeURIComponent(fileDownloadUrl)}&fileName=${encodeURIComponent(fileName)}`;

// 打开预览
window.open(previewUrl, "_blank");

参数说明:

参数 类型 必填 说明
url string 文件的网络下载地址,支持 http/https/ftp
fileName string 条件必填 真实文件名(含后缀),用于类型判断。如果 url 中已包含正确后缀(如 .docx),可不传
displayName string 在标题栏显示的文件名,不影响类型判断
watermark string 文字水印内容,支持 \n 换行,建议不超过两行
mode string 显示模式:normal(默认,带菜单栏)或 embed(嵌入模式,无菜单栏)

方式二:data 参数(Base64 编码,隐藏参数)

对于不希望在 URL 中暴露文件地址的场景,可以使用 data 参数将所有参数 Base64 编码后传递:

// 安装 js-base64:npm install js-base64
import { Base64 } from "js-base64";

const opts = {
    url: "https://app.example.com/api/files/download?id=12345",
    fileName: "年度报告.docx",
    displayName: "2025年度报告"
};

// Base64 编码
const base64Data = encodeURIComponent(Base64.encode(JSON.stringify(opts)));

// 构造预览 URL
const previewUrl = `https://app.example.com/fileview/preview/view?data=${base64Data}`;
window.open(previewUrl, "_blank");

CDN 引入方式:

<script src="https://cdn.jsdelivr.net/npm/js-base64@3.7.8/base64.min.js"></script>
<script>
    const opts = {
        url: "https://app.example.com/api/files/download?id=12345",
        fileName: "年度报告.docx"
    };
    const base64Data = encodeURIComponent(Base64.encode(JSON.stringify(opts)));
    const previewUrl = `https://app.example.com/fileview/preview/view?data=${base64Data}`;
    window.open(previewUrl, "_blank");
</script>

5.3 预览本地文件

如果文件已存在于 Fileview 容器可访问的磁盘路径上(比如通过 Docker 卷挂载共享目录),可以使用 path 参数替代 url

const filePath = "/opt/fileview/data/shared/report.docx";
const fileName = "report.docx";

const previewUrl = `https://app.example.com/fileview/preview/view?path=${encodeURIComponent(filePath)}&fileName=${encodeURIComponent(fileName)}`;
window.open(previewUrl, "_blank");

注意:path 是 Fileview 容器内部的文件路径,不是宿主机路径。如果使用 Docker 挂载 -v /host/files:/opt/fileview/data/shared,则对应容器内路径为 /opt/fileview/data/shared/xxx

5.4 两种传参方式对比

特性 query 参数方式 data 参数方式
实现复杂度 中(需 Base64 库)
URL 可读性 文件地址在 URL 中可见 参数被 Base64 编码,不直接可见
参数安全性 一般 有一定隐藏作用
适用场景 内部系统、开发调试 对外系统、安全要求较高场景
后端集成 简单字符串拼接 需 JSON 序列化 + Base64 编码

六、前端集成模式

6.1 新窗口打开(最简单)

function previewFile(fileUrl, fileName) {
    const url = encodeURIComponent(fileUrl);
    const name = encodeURIComponent(fileName);
    const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${url}&fileName=${name}`;
    window.open(previewUrl, "_blank");
}

适合:快速集成、对 UI 无特殊要求的场景。

6.2 iframe 嵌入(推荐)

<iframe
    id="file-preview"
    src=""
    width="100%"
    height="600px"
    frameborder="0"
    allowfullscreen
></iframe>

<script>
function previewInIframe(fileUrl, fileName) {
    const url = encodeURIComponent(fileUrl);
    const name = encodeURIComponent(fileName);
    // 使用 embed 模式去除 Fileview 自带的菜单栏
    const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${url}&fileName=${name}&mode=embed`;
    document.getElementById('file-preview').src = previewUrl;
}
</script>

mode=embed 参数会隐藏 Fileview 的顶部菜单栏,使预览内容更好地嵌入你的页面。

适合:文件详情页、审批流程页面、文档管理系统等需要"内嵌预览"的场景。

6.3 弹窗/抽屉预览

// 以弹窗模式预览(示例使用原生 JS)
function previewInModal(fileUrl, fileName) {
    const url = encodeURIComponent(fileUrl);
    const name = encodeURIComponent(fileName);
    const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${url}&fileName=${name}&mode=embed`;

    // 创建遮罩层
    const overlay = document.createElement('div');
    overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;';
    
    // 创建 iframe 容器
    const container = document.createElement('div');
    container.style.cssText = 'width:90%;height:90%;background:#fff;border-radius:8px;overflow:hidden;position:relative;';
    
    // 关闭按钮
    const closeBtn = document.createElement('button');
    closeBtn.innerText = '关闭';
    closeBtn.style.cssText = 'position:absolute;top:8px;right:12px;z-index:10;padding:4px 12px;cursor:pointer;';
    closeBtn.onclick = () => document.body.removeChild(overlay);
    
    // iframe
    const iframe = document.createElement('iframe');
    iframe.src = previewUrl;
    iframe.style.cssText = 'width:100%;height:100%;border:none;';
    
    container.appendChild(closeBtn);
    container.appendChild(iframe);
    overlay.appendChild(container);
    overlay.onclick = (e) => { if (e.target === overlay) document.body.removeChild(overlay); };
    document.body.appendChild(overlay);
}

适合:列表页"快速预览"、不离开当前页面查看文件的场景。


七、后端集成示例

7.1 Java (Spring Boot)

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

@Service
public class FilePreviewService {

    @Value("${fileview.base-url}")
    private String fileviewBaseUrl;  // 如 https://app.example.com/fileview

    /**
     * 生成文件预览 URL
     * @param fileDownloadUrl 文件下载地址
     * @param fileName 文件名
     * @return 预览 URL
     */
    public String buildPreviewUrl(String fileDownloadUrl, String fileName) {
        String encodedUrl = URLEncoder.encode(fileDownloadUrl, StandardCharsets.UTF_8);
        String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
        return String.format("%s/preview/view?url=%s&fileName=%s",
                fileviewBaseUrl, encodedUrl, encodedName);
    }

    /**
     * 生成带水印的预览 URL
     */
    public String buildPreviewUrlWithWatermark(String fileDownloadUrl, String fileName, String watermark) {
        String encodedUrl = URLEncoder.encode(fileDownloadUrl, StandardCharsets.UTF_8);
        String encodedName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
        String encodedWatermark = URLEncoder.encode(watermark, StandardCharsets.UTF_8);
        return String.format("%s/preview/view?url=%s&fileName=%s&watermark=%s",
                fileviewBaseUrl, encodedUrl, encodedName, encodedWatermark);
    }

    /**
     * 生成嵌入模式的预览 URL(无菜单栏)
     */
    public String buildEmbedPreviewUrl(String fileDownloadUrl, String fileName) {
        return buildPreviewUrl(fileDownloadUrl, fileName) + "&mode=embed";
    }
}

在 Controller 中使用:

@RestController
@RequestMapping("/api/files")
public class FileController {

    @Autowired
    private FilePreviewService previewService;

    @GetMapping("/{fileId}/preview-url")
    public Map<String, String> getPreviewUrl(@PathVariable String fileId) {
        // 从你的业务中获取文件信息
        FileInfo file = fileService.getById(fileId);
        String downloadUrl = fileService.generateDownloadUrl(fileId);

        String previewUrl = previewService.buildPreviewUrl(downloadUrl, file.getName());
        return Map.of("previewUrl", previewUrl);
    }
}

7.2 Python (Flask/Django)

from urllib.parse import quote

FILEVIEW_BASE_URL = "https://app.example.com/fileview"

def build_preview_url(file_download_url: str, file_name: str, 
                      watermark: str = None, embed: bool = False) -> str:
    """构造 Fileview 预览 URL"""
    encoded_url = quote(file_download_url, safe='')
    encoded_name = quote(file_name, safe='')
    
    preview_url = f"{FILEVIEW_BASE_URL}/preview/view?url={encoded_url}&fileName={encoded_name}"
    
    if watermark:
        preview_url += f"&watermark={quote(watermark, safe='')}"
    
    if embed:
        preview_url += "&mode=embed"
    
    return preview_url

7.3 Go

package preview

import (
    "fmt"
    "net/url"
)

const FileviewBaseURL = "https://app.example.com/fileview"

func BuildPreviewURL(fileDownloadURL, fileName string) string {
    return fmt.Sprintf("%s/preview/view?url=%s&fileName=%s",
        FileviewBaseURL,
        url.QueryEscape(fileDownloadURL),
        url.QueryEscape(fileName),
    )
}

func BuildEmbedPreviewURL(fileDownloadURL, fileName string) string {
    return BuildPreviewURL(fileDownloadURL, fileName) + "&mode=embed"
}

7.4 PHP

<?php
define('FILEVIEW_BASE_URL', 'https://app.example.com/fileview');

function buildPreviewUrl(string $fileDownloadUrl, string $fileName, 
                         ?string $watermark = null, bool $embed = false): string {
    $params = [
        'url' => $fileDownloadUrl,
        'fileName' => $fileName,
    ];
    
    if ($watermark !== null) {
        $params['watermark'] = $watermark;
    }
    
    if ($embed) {
        $params['mode'] = 'embed';
    }
    
    return FILEVIEW_BASE_URL . '/preview/view?' . http_build_query($params);
}

// 使用
$previewUrl = buildPreviewUrl(
    'https://app.example.com/download/report.docx',
    'report.docx',
    "内部文件\n仅供预览",
    true
);

八、关键功能:文字水印

Fileview 支持在预览时添加文字水印,适用于所有支持水印的文件格式,可用于防止截屏泄露。

// 水印内容支持 \n 换行,建议不超过两行
const watermark = encodeURIComponent("内部文件 仅供预览\n2026-04-12");

const previewUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${fileUrl}&fileName=${fileName}&watermark=${watermark}`;

水印是在预览时动态叠加的,不会修改原始文件。


九、关键功能:嵌入模式

嵌入模式(mode=embed)会隐藏 Fileview 自带的顶部菜单栏(包含文件名、工具按钮等),使预览内容区域全屏展示。

// 普通模式(默认)- 带菜单栏
const normalUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${fileUrl}&fileName=${fileName}`;

// 嵌入模式 - 无菜单栏
const embedUrl = `${FILEVIEW_BASE_URL}/preview/view?url=${fileUrl}&fileName=${fileName}&mode=embed`;

适用于将预览嵌入到你自己的 UI 框架中,由你的系统提供统一的顶栏和操作按钮。


十、安全配置

10.1 可信站点白名单(生产环境必须配置)

Fileview 在预览网络文件时,会先从指定 URL 下载文件。为防止 SSRF 攻击和非授权访问,应配置可信站点白名单,确保 Fileview 只从你的系统域名下载文件。

白名单配置写在 Fileview 的 application.yml 中。对于 Docker 部署,通过环境变量传入:

docker run -itd \
    --name fileview \
    -p 9000:80 \
    -e FILEVIEW_NETWORK_SECURITY_TRUSTED_SITES="app.example.com, *.internal.example.com" \
    --restart=always \
    basemetas/fileview:latest

或在配置文件中:

fileview:
  network:
    security:
      # 仅允许从这些域名下载文件
      trusted-sites: app.example.com, *.internal.example.com
      # 明确禁止的域名(优先级高于白名单)
      untrusted-sites: ""

规则说明:

  • 配置 example.com 会自动匹配其所有子域名
  • 支持通配符:*.example.com
  • 黑名单优先级高于白名单
  • 大小写不敏感
  • 多个规则用逗号分隔

推荐策略:

环境 配置建议
开发/测试 可不配置白名单(允许所有域名)
生产环境 必须配置白名单,仅允许你的系统域名
内网隔离 配置内网域名/IP

10.2 文件下载地址的安全性

你的系统生成的文件下载 URL 应具备基本的安全防护:

  • 临时链接:设置过期时间(如 30 分钟)
  • 签名校验:URL 包含签名参数,防止伪造
  • 权限校验:确保只有授权用户才能生成下载链接
// 推荐:生成带签名和过期时间的临时下载链接
const downloadUrl = generateSignedUrl(fileId, {
    expiresIn: 1800,  // 30 分钟
    signature: computeHmac(fileId + timestamp, secretKey)
});

10.3 加密文件处理

Fileview 支持预览带密码的 Office 文档(docx/xlsx/pptx)和加密压缩包(zip/rar/7z)。

处理流程:

  1. Fileview 检测到文件加密,返回 PASSWORD_REQUIRED 状态
  2. 前端弹出密码输入框
  3. 用户输入密码后,Fileview 验证并解密预览
  4. 密码加密存储在 Redis 中,30 分钟内同一客户端再次访问无需重复输入

十一、文件 URL 的对接要求

这是集成中最关键的一点:你的系统需要提供一个可供 Fileview 服务端下载文件的 URL

11.1 基本要求

  1. 可达性:Fileview 容器能够通过网络访问该 URL
  2. 直接下载:URL 访问后直接返回文件二进制流(而非 HTML 页面)
  3. Content-Type:建议返回正确的 MIME 类型(非必需,Fileview 主要靠 fileName 参数判断类型)

11.2 典型场景

场景 A:文件服务有公开下载接口

https://app.example.com/api/files/download?id=12345&token=xxx

直接将此 URL 作为 url 参数传给 Fileview 即可。

场景 B:文件存储在 OSS/S3

https://your-bucket.oss-cn-hangzhou.aliyuncs.com/files/report.docx?OSSAccessKeyId=xxx&Signature=xxx&Expires=xxx

使用预签名 URL,注意设置合理的过期时间。

场景 C:文件接口需要 Cookie/Token 认证

Fileview 的文件下载是服务端行为(不是浏览器行为),所以不会携带用户的 Cookie。

解决方案:

  • 方案一:生成不需要认证的临时下载链接(推荐)
  • 方案二:将 Token 作为 URL 参数传递(如 ?token=xxx
  • 方案三:使用 Fileview 的本地文件预览,让你的系统先把文件写入共享目录

场景 D:Fileview 和业务系统在同一内网

可以使用内网地址:

http://192.168.1.100:8080/api/files/download?id=12345

但需注意 Docker 网络。如果 Fileview 在 Docker 中,需确保容器能访问宿主机或其他容器的网络:

# 方案一:使用 host.docker.internal 访问宿主机(Docker Desktop 支持)
http://host.docker.internal:8080/api/files/download?id=12345

# 方案二:使用 Docker 网络
docker network create my-net
docker run --network my-net --name fileview ...
docker run --network my-net --name my-app ...
# 此时可用容器名访问:http://my-app:8080/api/files/download?id=12345

十二、Docker Compose 部署(推荐)

对于与业务系统联合部署的场景,推荐使用 Docker Compose:

version: '3.8'

services:
  fileview:
    image: basemetas/fileview:latest
    container_name: fileview
    ports:
      - "9000:80"
    volumes:
      - fileview-data:/opt/fileview/data
      - fileview-logs:/opt/fileview/logs
    restart: always
    networks:
      - app-network

  # 你的业务系统(示例)
  my-app:
    image: your-app:latest
    container_name: my-app
    ports:
      - "8080:8080"
    restart: always
    networks:
      - app-network

  # Nginx 反向代理
  nginx:
    image: nginx:alpine
    container_name: nginx
    ports:
      - "443:443"
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/ssl:/etc/nginx/ssl
    depends_on:
      - fileview
      - my-app
    restart: always
    networks:
      - app-network

volumes:
  fileview-data:
  fileview-logs:

networks:
  app-network:
    driver: bridge

在此配置下,Nginx 的代理配置可以使用容器名(fileviewmy-app)作为上游地址:

# nginx/conf.d/default.conf
server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;

    # 业务系统
    location / {
        proxy_pass http://my-app:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Fileview
    location /fileview/ {
        proxy_pass http://fileview:80/;
        proxy_set_header X-Forwarded-Prefix /fileview;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Port $server_port;
        proxy_set_header X-Forwarded-Host $host;
    }
}

十三、集成自检清单

部署和集成完成后,按以下清单逐项验证:

部署验证

  • 访问 Fileview 欢迎页正常显示
  • 如使用反向代理,通过代理地址访问欢迎页正常
  • 如使用子路径部署,确认 {baseUrl}/preview/welcome 可访问

预览功能验证

  • 使用 url 参数预览一个公开可下载的 PDF 文件
  • 使用 url 参数预览一个 DOCX 文件(需经过格式转换)
  • 使用 url 参数预览一个图片文件
  • 如有本地文件场景,使用 path 参数预览本地文件
  • 验证 mode=embed 嵌入模式生效(无菜单栏)
  • 验证 watermark 水印参数生效

安全验证

  • 配置可信站点白名单后,尝试预览非白名单域名的文件(应被拒绝)
  • 确认文件下载 URL 有适当的鉴权和过期机制

网络验证

  • Fileview 容器能够访问你的文件下载地址
  • 如在 Docker 内,确认容器间网络互通
  • HTTPS 场景下,确认预览 URL 生成为 HTTPS

十四、常见问题排查

问题 1:预览页面白屏或长时间加载

排查步骤:

  1. 查看浏览器 Network 面板,确认预览 URL 请求是否成功(HTTP 200)
  2. 检查 Fileview 容器日志:docker logs fileview
  3. 确认 Fileview 能否下载文件:进入容器测试 curl <文件下载URL>
  4. 对于 Office 文件,首次预览需要格式转换,可能需要几秒到十几秒

问题 2:预览 URL 中的地址不正确

原因: 反向代理未正确传递 X-Forwarded-* 请求头。

解决: 检查 Nginx 配置中是否包含完整的代理头设置,特别是 X-Forwarded-ProtoX-Forwarded-HostX-Forwarded-Prefix

问题 3:文件下载失败

排查步骤:

  1. 确认文件下载 URL 在 Fileview 容器内可访问
  2. 检查白名单配置是否包含文件所在域名
  3. 检查文件下载 URL 是否已过期
  4. 如使用 Docker,检查容器网络配置(DNS 解析、网络连通性)

问题 4:中文文件名乱码

解决: 确保所有参数都经过 encodeURIComponent / URLEncoder.encode 编码。

问题 5:OFD 文件中文显示为方框

原因: 缺少中文字体(思源字体)。

解决: 参考常见问题文档中的字体安装说明。


十五、生产环境最佳实践

15.1 必做事项

  1. 配置反向代理:不直接暴露 Docker 端口
  2. 启用 HTTPS:在 Nginx 层终止 SSL
  3. 配置可信站点白名单:限制文件下载来源
  4. 挂载数据卷:持久化文件存储和日志
  5. 设置容器自动重启--restart=always

15.2 建议事项

  1. 使用子路径部署:避免跨域问题
  2. 生成带签名和过期时间的文件下载 URL
  3. 监控容器资源:关注 CPU、内存、磁盘使用率
  4. 定期查看日志:及时发现转换失败等异常

15.3 性能优化

对于高并发场景,可参考性能调优指南进行以下优化:

  • JVM 堆内存调整(通过 Docker 环境变量)
  • 转换线程池并发度控制
  • Redis 连接池调优
  • 长轮询策略调整

十六、支持的文件格式速查

类别 格式
Office doc, docx, wps, rtf, txt, md 等文档类;xls, xlsx, csv, ods 等表格类;ppt, pptx, odp 等演示类
版式文档 pdf, ofd
图片 jpg, png, gif, bmp, svg, webp, psd, tif, tga, emf, wmf
CAD dwg, dxf
3D 模型 gltf, glb, obj, stl, fbx, ply, dae, wrl, 3ds, 3mf, 3dm
流程图/思维导图 vsd, vsdm, vsdx, vssm, vssx, vstm, vstx, bpmn, drawio, xmind
压缩包 zip, jar, rar, 7z, tar, tar.gz
代码 java, kotlin, scala, python, go, rust, c, cpp, js, ts, vue, php, ruby, shell 等 100+ 语言
音视频 mp4, mp3, webm, wav, aac, ogg, flac, avi, mkv, mov, flv
电子书 epub
文本 html, xml, json, yaml, toml, ini, conf

完整列表参见 支持格式


相关资料

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

作者 Justin3go
2026年4月12日 14:43

丢掉沉重的记忆:Codex、Claude Code 与 OpenCode 的上下文压缩术

在使用 AI Agent 深度参与编程任务时,你一定遇到过这种窘境:起初 AI 反应敏捷,指哪打哪;但随着对话轮次增加,它似乎开始变得越来越笨。

上下文快用完的时候,AI会着急完成导致效果不佳,社区中称作 Context Anxiety (上下文焦虑),和我们人一样,着急就容易出错。为了维持对话,Agent 必须丢掉一部分记忆(压缩 - Compact)。但怎么丢、丢掉谁、丢掉后怎么补救,成了衡量一个 Agent 运行时(Runtime)是否成熟的分水岭之一。

今天笔者就带大家拆解三款主流 CLI Agent——Codex CLIClaude CodeOpenCode。看看它们在面对同一个登录 Bug 时,是如何施展各自的"压缩大法"的。

注:本文分析基于 Codex CLI 与 OpenCode 的开源仓库逻辑,以及社区逆向研究与泄露源码对 Claude Code 运行时的验证。

场景回放:一场价值 15,400 Tokens 的登录修复

假设你正在修复一个登录接口报 401 Unauthorized 的 Bug。你召唤了 AI Agent,并经历了一番激烈的排查。

下面是这段对话的完整记录:

编号 角色 内容摘要 预估 Token
#1 System 系统提示词(含 40+ 工具定义) ~800
#2 User "登录页面报 401,帮我排查下" ~100
#3 Assistant "我先搜一下认证相关的逻辑" ~150
#4 Tool Call grep "auth" --include="*.ts" ~50
#5 Tool Result (返回 50 处搜索结果) ~2,000
#6 Assistant "搜到几处,我看看 auth.ts" ~150
#7 Tool Call read_file "src/auth.ts" ~50
#8 Tool Result (完整文件内容,约 300 行) ~3,500
#9 Assistant "找到了,token 校验没处理过期" ~300
#10 User "好,但要兼容旧的 Session 方式" ~80
#11 Assistant "明白,我再看看 middleware" ~150
#12 Tool Call read_file "src/middleware.ts" ~50
#13 Tool Result (middleware 完整内容) ~2,500
#14 Assistant "middleware 也要改,我来处理" ~200
#15 Tool Call edit_file "src/auth.ts" (patch) ~100
#16 Tool Result "Successfully applied." ~30
#17 Tool Call edit_file "src/middleware.ts" (patch) ~100
#18 Tool Result "Successfully applied." ~30
#19 Assistant "代码改好了,跑个测试看看" ~100
#20 Tool Call bash "npm test" ~50
#21 Tool Result (3 个测试失败,含堆栈) ~3,000
#22 Assistant "有 3 个测试挂了,我修一下测试用例" ~200
#23 Tool Call edit_file "src/auth.test.ts" (patch) ~150
#24 Tool Result "Successfully applied." ~30
#25 Tool Call bash "npm test" ~50
#26 Tool Result (测试全部通过,含完整输出) ~1,500

看起来不过 26 条消息,但已经吃掉了约 15,400 tokens。其中加粗的五条工具结果(#5, #8, #13, #21, #26)合计约 12,500 tokens,占了 81% 。这些数据在排查时至关重要,但 Bug 修好后,它们就变成了上下文里沉重的负担。如果不处理,下一轮对话可能因为窗口溢出而丢掉系统提示词或用户的核心需求。

Codex CLI:写一份干练的"工作交接单"

OpenAI 的 Codex CLI(源码,Rust 实现)走的是一种非常符合人类直觉的路线:总结与替换

它的核心思想可以用一句话概括:把之前的全部对话交给 LLM 写一份"工作交接摘要",然后用这份摘要替换掉原始历史。

双路径设计

Codex 提供了两条压缩路径:

  1. 本地路径compact.rs):在客户端调用 LLM 生成摘要,适用于所有模型提供商。
  2. 远程路径compact_remote.rs):直接调用 OpenAI 的内部 API 端点 responses/compact,让服务器完成压缩。仅限 OpenAI 自家模型。

注意,这里的"本地"和"远程"指的不是是否需要调用 LLM——两条路径都需要 LLM 参与,区别在于 "生成摘要"这个核心步骤跑在哪里。本地路径下,客户端自己构造摘要 Prompt(从内置模板 templates/compact/prompt.md 加载)、通过 ModelClientSession 流式调用 LLM API、再处理返回结果,整个编排流程都在你的机器上完成,所以它能对接任意模型提供商。远程路径下,客户端把准备好的对话历史和工具定义发给 OpenAI 的 compact_conversation_history 端点,由服务器完成摘要生成——但客户端并非"甩手掌柜",它在调用前后仍然承担了大量工作:调用前要修剪过长的函数调用历史、构建包含工具规范和系统指令的完整 Prompt 对象;调用后要过滤返回结果(比如丢弃过时的 developer 角色消息、只保留真实的用户和助手内容)、恢复用于 /undo 功能的 ghost snapshots、以及重新计算 token 用量。

简单说,远程路径只是把 "压缩"这一步外包给了 OpenAI 服务器,前处理和后处理仍由客户端完成。这种设计的优势在于:OpenAI 服务端很可能对这个端点做了专门优化(比如使用更经济的模型或内部缓存),这些是客户端走通用 API 做不到的。这体现了 OpenAI 对自家基础设施的垂直整合。

压缩的具体流程

当走本地路径时,Codex 会先提取最近的用户消息(硬上限约 20,000 tokens),然后发送一段简短的 Summarization Prompt 给 LLM。这段 Prompt 只有 4 个核心要点:

你正在执行一次"上下文检查点压缩"。请为另一个将接续任务的 LLM 生成一份交接摘要,包含:当前进展和关键决策、重要的约束和用户偏好、剩余待办事项、继续工作所需的关键数据。

关键词是 "交接"(Handoff) ——它不是在写会议纪要,而是在写一份让下一个人(模型)能直接上手的工作简报。

用我们的登录 Bug 场景来看:

Codex CLI 压缩前后对比

思路拆解:

注意看压缩前后的变化——所有消息变成了 4 条。Codex 极其尊重"用户意图",它会物理删除所有的 Assistant 回复和 Tool 相关消息,但会原封不动地保留所有 User 消息(#2 和 #10)。

随后,它插入一条伪造的 Assistant 消息,内容是一份结构化的交接总结。这份总结包含了任务目标、已完成项、关键架构决策和剩余待办。对于新模型来说,它不需要看那些大段的文件内容和测试堆栈,它只需要知道"测试已经修好了"就足够了。

自动触发与兜底

当 Token 用量接近模型上下文窗口上限时,Codex 会自动触发压缩(不需要用户手动执行 /compact)。如果压缩后空间还是不够,它会退而采取更激进的"头部修剪"——直接从最早的消息开始砍,确保对话能继续下去。

笔者觉得 Codex 的方案最大的优点是直觉性:交接摘要这个概念每个职场人都能理解。缺点是它比较"一刀切"——所有 AI 回复和工具结果都被替换成一段摘要,如果那段摘要遗漏了某个关键细节,就真的找不回来了。

Claude Code:三层递进的"精密遗忘"

Anthropic 出品的 Claude Code 逻辑更为细腻。它不追求一步到位的物理删除,而是设计了三层逐级加强的清理机制——从轻到重,能不动 LLM 就不动 LLM。

注:Claude Code 非开源项目,以下分析基于社区逆向工程和公开资料,具体实现可能随版本变化。

第一层:工具结果修剪(无 LLM 开销)

这是最频繁、也最轻量的一层。不需要调用 LLM,纯粹是本地的规则引擎。它在每次请求前都会自动执行。

它的逻辑很简单:

  • 始终保护最近若干个工具调用的结果(正在用的东西不能删)
  • 超出保护范围的旧工具结果 → 替换为 [Old tool result content cleared] 占位符

用我们的场景来看:

Claude Code 第一层压缩

这种做法极其聪明:它维护了 AI 的"心流"。AI 记得自己搜过代码(#4 的 tool_call 还在),也记得自己读过文件(#7 的 tool_call 还在),只是不记得搜到了什么、文件内容是什么。如果它之后真的需要再次查看,它会自己重新发起 read_file

笔者认为这一层的设计极为精妙——它实现了 "选择性失忆"而非"全面遗忘" 。就像你记得去年读过一本好书,但忘了具体内容,需要的时候再翻就好。

第二层:缓存友好策略(Prompt Cache)

这是 Claude Code 的看家本领,也是三者中独有的差异化优势

Anthropic 的 API 支持 Prompt Cache——如果你发给 API 的消息前缀和上一次请求相同,服务器可以复用之前的计算结果,大幅降低成本和延迟。

这意味着什么?在清理消息时,Claude Code 会尽量避免修改消息序列的前半部分。它采用"手术式"方案:只在尾部进行修整,确保消息开头部分保持绝对一致。这样做的代价是清理效率略低,但换来的是缓存命中率的最大化

用我们的场景来看。假设经过第一层清理后,消息序列是 #1-#26(工具结果已替换为占位符)。现在上下文仍然超标,需要进一步裁剪。一个"朴素"的做法是从最早的消息开始删——但 Claude Code 不这么干

缓存策略对比

左边的朴素策略虽然删掉了最旧的消息,看起来很合理,但代价是整个前缀都变了——API 缓存全部失效,下次请求要从头计算。右边的 Claude Code 策略则相反:它宁可少删一些,也要保证消息序列的前缀部分和上一次请求完全相同,让 Anthropic API 的 Prompt Cache 能够命中。

在长时间运行的任务中(比如你连续让 AI 帮你重构一整个模块),这种策略能带来可观的成本节省——因为每次 API 请求的大部分内容都能命中缓存,只需要为新增的尾部内容付费。

第三层:9 部分结构化 LLM 总结(最后手段)

当前两层都无法阻止上下文继续膨胀时,系统触发最终的全量总结。根据源码,自动压缩的触发阈值为 有效上下文窗口 - 13,000 tokens(其中有效窗口 = 模型上下文窗口 - min(最大输出 tokens, 20,000))。

不过,即使达到了阈值,系统也不会直接跳到 LLM 总结。自动压缩触发时,系统会优先尝试 Session Memory Compact——利用 session memory(会话记忆)中已有的结构化信息来替代完整的 LLM 调用。这意味着大多数自动压缩甚至不需要 LLM 调用。只有当 session memory 路径不可用或不够时,系统才会回退到传统的 LLM 总结流程,生成一份包含 9 个固定部分的结构化摘要

  1. 用户的原始意图
  2. 核心技术概念
  3. 关注的文件和代码
  4. 遇到的错误及修复方式
  5. 解决问题的逻辑链
  6. 所有用户消息的摘要
  7. 待办事项
  8. 当前正在做什么
  9. 建议的下一步

这份摘要的要求极其严格——Prompt 中会要求模型直接引用原文关键短语,而不是全部用自己的话改写。这是为了防止"语境漂移"(模型在复述过程中微妙地偏离原意)。

用我们的场景来看:

Claude Code 第三层压缩

压缩完成后,Claude Code 还会做一系列善后工作,笔者把它叫做 "状态重构"

  • 在新对话开头注入引导语("本次会话延续自上一段对话...")
  • 自动重新读取最近编辑过的文件(最多 5 个文件,总预算 50,000 tokens,单文件上限 5,000 tokens),确保 AI 手里有最新代码
  • 重新声明工具和技能定义
  • CLAUDE.md 中的项目规范作为系统提示语的一部分,始终常驻,不受压缩影响

用户还可以在手动压缩时附加自定义指令,比如 /compact Focus on API changes,引导压缩侧重于特定方向。

此外,系统还有一条被动兜底路径:当 API 返回 prompt_too_long 错误时,系统会自动启动一次反应式压缩并重试请求,确保用户不会因为上下文溢出而直接遇到错误中断。同时,为防止压缩反复失败导致的死循环,连续 3 次自动压缩失败后系统会暂停自动压缩功能。

Claude Code 的方案是三者中最复杂的,但也是最"省钱"的——大多数时候它只需要执行第一层的规则引擎清理,或者通过 Session Memory 路径完成压缩,根本不需要额外的 LLM 调用。

OpenCode:先修剪,再摘要的"阶梯治理"

开源界的新秀 OpenCode(源码,TypeScript + Effect-TS 实现)则提供了一种更为平衡的策略。它在 session/compaction.ts 中实现了一套阶梯式的治理流程:先用低成本手段尽可能腾空间,实在不够再动用 LLM。

第一步:Prune(标记隐藏,非物理删除)

OpenCode 的第一个动作不是删除,而是"标记"。它的规则非常清晰:

  • 只有当修剪能释放超过 20,000 tokens 时才执行(小修小补不值得折腾)
  • 始终保留最近的 40,000 tokens 作为"安全垫"(正在进行的工作不能动)
  • skill 类型的工具输出永远不修剪(因为里面包含操作指令)
  • 保护最近 2 个用户回合的完整内容

关键设计:和 Claude Code 的占位符替换不同,OpenCode 的修剪不是物理删除,而是给旧消息打上一个 compacted = Date.now() 的时间戳标记,让它们在后续请求中"不可见"。数据其实还在数据库里,只是被隐藏了。

OpenCode Prune

关键点: 数据并没有真正丢掉。这为未来可能的历史回溯功能留下了空间——如果开发者需要审计,或者 Agent 触发了某种回溯逻辑,这些数据是可以被重新拉回上下文的。这是一个很有前瞻性的设计。

第二步:LLM 5 标题摘要

如果 Prune 之后还是太臃肿,OpenCode 会用一个隐藏的、专门的 Agent(不干扰用户当前的交互)来调用 LLM 生成一份摘要。这份摘要有一个固定的 5 标题结构:

OpenCode LLM 摘要

OpenCode 在摘要后有一个非常温馨的设计:它会自动重放最后一条用户消息。这能确保 Agent 的最后记忆点始终停留在用户的最新指令上,而不是停留在一段冷冰冰的摘要总结里。用户完全感知不到压缩的发生——你说的最后一句话会被重新发送,AI 继续回答,好像什么都没发生过。

另一个亮点:OpenCode 会跟随用户的语言。如果你一直用中文交流,它的摘要也会是中文的。这对非英语母语的开发者来说,是一个很友好的设计。

笔者觉得 OpenCode 的方案在三者中最"开发者友好"——代码全开源(TypeScript),架构现代(Effect-TS),非物理删除的设计为扩展留足空间。如果你想深度定制压缩行为,OpenCode 是最容易上手的。

三剑客同台竞技

我们将三者的方案放在一起并排观察:

输入:26 条消息, ~15,400 tokens(同一个"修登录 bug"场景)

三剑客对比

维度 Codex CLI Claude Code OpenCode
压缩层次 单层(摘要) 三层(修剪/缓存/摘要) 两层(隐藏/摘要)
LLM 调用 必须 仅在第三层 仅在第二步
用户消息 永久保留原始内容 摘要化(第三层) 摘要化 + 重放最后一条
工具结果处理 物理删除 占位符替换 时间戳标记隐藏
缓存优化 无特殊设计 深度集成 Prompt Cache 侧重减少重复读取
压缩后行为 被动等待 主动重读相关文件 自动重放最后指令

一些值得展开说的差异

关于"要不要保留用户原话" :Codex 选择保留用户消息、只压缩模型回复,这样做的好处是 AI 永远能回看你说过什么,但代价是当用户消息本身很长时,压缩效率会打折扣。Claude Code 和 OpenCode 则选择全部压缩为摘要,更激进但更节省空间。

关于缓存:这是 Claude Code 最独特的优势。其他两家在压缩后,API 请求的内容会发生很大变化,之前的缓存基本作废。而 Claude Code 刻意维持消息前缀的稳定性,使得压缩后的请求依然能复用之前的缓存。在长时间运行的任务中,这意味着可观的成本节省。

关于"非物理删除" :OpenCode 的时间戳标记方式是个很有前瞻性的设计。虽然当前版本并没有实现历史回溯功能,但数据没有真正丢失,为未来留下了可能性。而 Codex 和 Claude Code 的压缩都是不可逆的。

最后

如果用一个类比来形容这三位:

  • Codex CLI 像是一个写交接单的资深员工。他直接撕掉之前的草稿纸,给你一张写的清清楚楚的现状说明,虽然简单粗暴,但非常有效。
  • Claude Code 像是一个拥有精密遗忘能力的学者。他优先划掉书上的细碎批注,只有在书架实在堆不下时,才会把整本书浓缩成一页大纲。他非常在意翻书的效率(缓存)。
  • OpenCode 像是一个务实的阶梯治理者。他先给旧文件打包贴上标签(隐藏),实在不行才做总结。他最贴心的地方在于,总结完后还会提醒你:"你刚才最后说的是这件事对吧?"

归根结底,在 2026 年,最好的上下文管理并不是无止境地扩大 LLM 的记忆容量,而是学会如何精密地遗忘。毕竟,一个什么都记得住的 Agent,往往也最容易被噪音干扰。


参考来源:

连载05-Claude Skill 不是抄模板:真正管用的 Skill,都是从实战里提炼出来的

2026年4月11日 15:33

别再直接 Fork 别人的 Claude Skill:真正有用的 Skill,都是从项目里长出来的

AI Coding 系列第 05 篇 · 核心工具

我第一次批量导入公开 Skill 模板的时候,是真的以为自己走了捷径。

GitHub 上一堆 star 很高的仓库,code review、需求分析、文档编写、调研、拆任务,看起来什么都有。我当时的想法很直接:既然别人已经把常见工作流整理好了,我直接 fork 一份,全量导入,不就能让 Claude 立刻更稳、更懂项目吗?

结果用了几天,我反而越来越不放心。

不是因为它“明显做错了什么”,而是因为它总在看起来没问题的地方出问题。格式完整,措辞专业,检查项也不少,可真正让我在项目里反复吃亏的那几件事,它一次都没替我盯住。异步链里是不是又漏了 await,这次 migration 有没有回滚方案,新同学是不是又顺手写了 throw new Error(),数据库 schema 改了之后 Prisma 类型是不是也一起更新了。

它会提醒一堆“大家普遍都应该注意”的东西,却不知道“我们团队到底最怕什么”。

后来我才明白,问题不是 Skill 机制不好,而是我导入的根本不是“自己的 Skill”,只是别人整理好的经验。

这些经验当然有价值,但它们解决的是共性问题,不会天然长成你项目里的“肌肉记忆”。

真正有用的 Skill,恰恰应该做一件事:

把那些你本来总要重复提醒、总会漏掉、总会在项目里反复踩坑的动作,固化成默认动作。

也就是一句话:

Skill 的本质,不是收藏经验,而是固化默认动作。

这篇文章会从最基本的边界讲起,一路走到 SKILL.md、源码机制、任务类型和可执行能力。内容不少,但我尽量只保留真正有助于你在项目里把 Skill 用起来的部分。


NotebookLM Mind Map.png

先说结论

如果你只记住这几条,这篇文章就已经值回时间:

  • 对所有任务都生效的规则,写进 CLAUDE.md;只对某类重复任务生效的,做成 Skill;只对这一次有效的,写进 Prompt
  • 只有“输入相对稳定、输出有模式、而且容易漏步骤”的任务,才值得沉淀为 Skill
  • 通用 Skill 模板只能当原材料,项目级 Skill 必须自己裁剪、自己维护
  • description 不是装饰字段,它承担了触发场景的职责,最好把 Use when... 直接写进去,关键词前置、长度克制
  • Claude 启动时主要只看 frontmatter,Skill 正文在真正触发时才按需载入
  • allowed-tools 是权限边界,不是行为建议;paths 是条件激活,不是说明文字
  • 第一个 Skill 不要挑最关键的任务,先拿中等风险任务练手

一、公开 Skill 模板为什么一开始很香,后来却越用越别扭

我现在反而会对“看起来很全”的公开 Skill 模板保持一点警惕。

不是因为它们没用,而是因为它们太容易制造一种错觉:好像什么都覆盖到了,但真正最重要的东西其实没进去。

公开模板最常见的问题,不是方向错,而是下面这三种。

1. 太宽泛

它什么都管一点,但什么都不够深。

它会告诉你“注意异常处理”“注意性能”“注意安全”,这些当然没错。但这些话本身不构成你项目里的工作流。它不知道你们统一用的是 AppError,不知道你们数据库变更必须检查回滚,也不知道你们哪几个目录历史包袱最重。

2. 太嘈杂

50 行模板里,真正有价值的可能只有 5 行。

剩下的 45 行不是完全没用,而是在和那 5 行争夺 Claude 的注意力。对于 agent 来说,规则不是越多越强。很多时候,8 行写透项目约束的 Skill,比 50 行“样样都提一点”的模板更有用。

3. 太不像你的项目

这点最致命。

公开模板知道“大家普遍应该注意什么”,但不知道“你们团队反复死在哪些地方”。而真正有价值的 Skill,恰恰应该把那些项目特有、团队高频踩坑的东西固化下来。

说得更直白一点:你把一个新同事扔进团队,给他一份行业通用培训材料,当然比什么都不给强;但如果你不告诉他“我们团队最容易出错的是哪三件事”,他依然干不好你最在意的活。

所以正确姿势不是“找一个最全的模板直接用”,而是:

先借鉴,再裁剪,最后只留下真正属于你项目的那几条。

公开模板到项目 Skill 的提炼路径


二、先把 Prompt、CLAUDE.md、Skill 这三件事彻底分清楚

很多人不是不会写 Skill,而是一开始就把这三件事混在一起了。

判断方法其实很简单,只问一个问题:

这个要求的作用范围到底有多大?

  • 这个要求对所有任务都成立吗?如果是,放 CLAUDE.md
  • 这个要求只对某一类任务成立吗?如果是,做成 Skill
  • 这个要求只对这一次成立吗?如果是,写进 Prompt

举几个特别典型的例子:

“所有 throw 必须是 AppError
这是全局规则。不管你是在写新功能、修 bug,还是做重构,都要遵守。它应该进 CLAUDE.md

“代码审查时按固定顺序检查数据库、异步和错误处理”
这只在 code review 这种任务里才触发,它不是全局规则,而是任务模板,所以应该做成 Skill

“这次先只分析原因,不要动代码”
这只对当前这次任务有效,应该写进 Prompt

最容易搞混的是 CLAUDE.mdSkill。它们都能约束 Claude 的行为,但本质完全不同:

  • CLAUDE.md 是永远生效的规则
  • Skill 是遇到对应任务才触发的模板

如果要打个比方:

  • CLAUDE.md 是交通规则
  • Skill 是导航路线
  • Prompt 是你这次上车前临时交代的一句话

这三层一旦分清楚,后面 80% 的混乱都会自动消失。

Prompt、CLAUDE.md、Skill 的边界图

一个常见误判:很多问题根本不需要写 Skill

我后来发现,很多人想写 Skill,并不是因为真的存在一个稳定、重复、值得沉淀的任务,而是因为这一次和 Claude 协作得不顺

比如目标没说清,边界没收紧,上下文没给够,或者你真正缺的是一条全局规则,却误以为自己需要一份任务模板。这个时候你如果急着把它沉淀成 Skill,本质上只是把一次性的混乱模板化。

几个很常见的误判场景是:

  • 这次需求本身还在摇摆,连你自己都没想清楚要什么
  • 这个问题只发生过一次,下次未必还会以同样的形状出现
  • 你真正缺的是全局约定,比如错误处理、目录规范、命名规则
  • 你只是想表达“这次先别改代码”“这次先只分析原因”这种一次性约束

写 Skill 之前,先问自己一句话:

这个问题下次还会以差不多的形状再来一次吗?如果不会,先别急着写 Skill。


三、什么时候一个任务真的值得被沉淀成 Skill

不是所有重复任务都值得沉淀。

我现在给自己的标准其实很克制,就一句话:

同一类任务做了三次以上,而且每次都要重新给 Claude 解释背景。

反过来说,如果某个任务每次背景和目的都完全不同,就不值得沉淀。比如“写文档”这个动作本身很常见,但公司文档、API 文档、用户手册的写法完全不同,它们应该是三个不同的 Skill,而不是一个叫“写文档”的通用模板。

在真正开始写之前,我会先做三个检查。

1. 输入是否稳定

“根据 Figma 设计稿生成 React 组件”这种任务,输入格式相对稳定,比较适合沉淀。

“根据 SQL 查询结果生成图表”这种任务,每次数据格式和图表类型都可能差很多,Skill 会很难写得稳。

2. 输出是否有共同模式

“写 Pull Request 描述”很适合,因为它天然就有固定框架:改了什么、为什么改、怎么测试。

但“和 AI 讨论技术方案”这种任务,每次深度、重点、结论都不同,就不太适合硬沉淀成一个模板。

3. 有没有容易漏掉的关键步骤

最值得沉淀成 Skill 的任务,通常不是“最复杂”的任务,而是那些不特别提醒就容易漏一步的任务。

Skill 最有价值的地方,不是让 Claude 变得更聪明,而是把你每次最容易忘的检查项,固化成默认动作。

所以一个任务如果同时满足下面三点:

  • 输入相对稳定
  • 输出有共同模式
  • 总有一两步容易漏

它就很值得沉淀成 Skill。

什么任务值得沉淀成 Skill


四、从一个真实痛点开始,走完 Skill 的提炼过程

光讲判断标准还是有点抽象,不如走一遍完整例子。

代码审查,几乎每个后端工程师每周都在做,也是最容易进入“重复解释”困境的任务。用它来走一遍完整的 Skill 提炼过程会很清楚。

你反复踩的坑

假设你们团队每周都做代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

先设计内容,再去想格式

一个好 Skill,先别急着写文件。先把内容层想清楚,只要回答四个问题:

1. 什么时候用

不是写“代码审查”四个字,而是写清楚触发场景。

❌ 代码审查
✅ 当我提交 PR 前,检查我的 TypeScript 后端代码是否符合项目约定

差别在于:模糊的描述会让 Claude 在不该用的时候乱触发,而具体的场景描述更容易精准命中。

2. 按什么顺序做

步骤尽量不要超过五步。

你从公开模板里借灵感,但通用模板有 50 行,而你真正关心的可能只有四件事:改动范围、错误处理、异步操作、数据库查询。

1. 读完整个改动的 diff,确认改动是否只涉及这个 PR 的范围
2. 检查错误处理:所有 throw 都必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题,关键查询是否 explain 过

3. 输出长什么样

不要写“请清晰输出”。这种话几乎没有约束力。直接给格式。

🔴 Critical: ...
🟡 Warning: ...
✅ Suggestion: ...
Summary: X critical issues to fix before merge.

4. 什么时候不适用

写清楚边界比写清楚功能更重要。

比如:

  • 不审查 UI 层代码
  • 不关注代码风格
  • 改动超过 500 行先拆 PR

这些“我不做什么”的声明,往往比“我会做什么”更能防止 Claude 越界。

到这里,你脑子里其实已经有一个能用的 Skill 了。下一步只是把它放进 Claude Code 认识的格式里。


五、真正落到 SKILL.md 文件层,哪些字段值得你认真写

一个完整的 SKILL.md,通常会长这样:

---
name: code-review
description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
arguments:
  - target
---

# Code Review

## 步骤
1. 读取 ${target} 的改动 diff
2. 检查错误处理:所有 throw 必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题

## 输出格式
🔴 Critical: ...
🟡 Warning: ...
✅ 通过: ...

这里最值得你认真写的,其实是下面几个字段。

name
名字别太抽象。要让人一眼知道它是做什么的。像 helperutilstools 这种名字几乎没有路由价值,远不如 code-reviewpr-summaryapi-conventions 这种具体命名。

description
这是现在最关键的字段。它不只是“简介”,还承担了“触发场景”的职责。你最好直接把 Use when... 写进去,而不是写一句空话。更重要的是,别把它写成长段说明文。关键词尽量前置,长度最好控制在 250 字符左右,太长往往只会稀释命中信号。官方还特别提醒,description 最好用第三人称去写,像 “Analyzes pull requests...” 这种句式,比 “I can help...” 或 “You can use this...” 更稳。

allowed-tools
它决定这个 Skill 具备哪些能力。这个字段后面我会在源码部分展开讲,因为它比很多人想象的更“硬”。

arguments
让 Skill 接受参数,比如目标文件、目录、分支名。${target} 会在正文里被替换成你传进去的实际值;如果你喜欢按位置拿参数,也可以用 $0$1 这类方式。

还有几个很好用,但不是每次都要上的字段。

argument-hint
告诉调用者这个 Skill 期待什么参数。

model: haiku
简单任务可以指定更轻量的模型,直接省成本。像格式化、重命名、简单改写这类工作,很多时候没必要上更重的模型。

paths
让 Skill 只在某些路径下激活。适合模块边界明确的项目。

context: fork
高风险操作放进独立上下文,避免污染主会话。

disable-model-invocation: true
禁止 Claude 自动触发,只允许你手动 /skill-name 调用。部署、发版、发邮件这类有副作用的 Skill,应该优先考虑加上。

大多数 Skill 根本不需要把字段填满。真正实用的思路不是“功能全”,而是“正好够用”。

如果一个 Skill 只是做常规代码审查,namedescriptionallowed-toolsarguments 往往就够了。只有当你真的遇到参数化、模块隔离、上下文隔离这些需求时,再往上加。

SKILL.md 不是整个 Skill,它只是入口

很多人以为一个 Skill 就是一份 SKILL.md。其实不是。

更实用的做法通常是:把 SKILL.md 控制在足够短、足够清楚的范围里,让它承担“入口”和“调度”职责;真正长的规范、示例、脚本都拆出去。

一个 Skill 目录完全可以长这样:

my-skill/
├── SKILL.md
├── reference.md
├── examples/
│   └── sample.md
└── scripts/
    └── helper.py

这里的关键点不是“可以放很多文件”,而是:这些文件不会自动加载,必须在 SKILL.md 里显式引用。

比如:

## 参考资料
- 完整的 API 规范见 [reference.md](reference.md),需要查接口细节时读它
- 期望的输出格式见 [examples/sample.md](examples/sample.md)

这个设计和前面说的懒加载是同一套思路:不是 Skill 触发时把所有材料都灌进上下文,而是只在真正需要的时候再去读。

所以:

  • reference.md 适合放项目特有知识,比如内部 API 规范、禁用库、架构约定
  • examples/ 适合放期望输出样例,帮助 Claude 对齐格式
  • scripts/ 适合放真正可执行的辅助脚本,让 Skill 不只是“描述怎么做”,还能“先把上下文准备好”

这点很重要,因为它决定了 Skill 的上限不是“几行 prompt”,而是“一个有入口、有知识、有执行能力的局部工作流”。

Skill 放在哪,决定它是谁的能力

这点很容易被忽略,但工程上很重要。

同样是一个 Skill,放在不同位置,意义完全不一样:

  • ~/.claude/skills:你个人所有项目都能用,适合个人长期习惯
  • .claude/skills:只在当前项目生效,适合团队项目约定
  • <plugin>/skills:跟着插件走,适合做模块化分发

如果不同层级里恰好有同名 Skill,优先级也不是平均的。官方规则更接近:企业级配置优先于个人级,个人级优先于项目级;插件 Skill 因为带命名空间,通常不会和前面这些直接撞名。

官方文档里甚至把这件事讲得很直接:Skill 存放的位置,本身就是它的作用域设计。

这背后的工程含义非常大。

如果你把一个强项目耦合的 Skill 放进个人目录,它就会带着这个项目的假设跑到别的仓库里;反过来,如果你把一个本该跨项目复用的通用 Skill 只塞在项目目录里,它的复用价值又被锁死了。

在 monorepo 里,这件事更有意思。Claude Code 会自动发现子目录下的 .claude/skills/。也就是说,你完全可以让 packages/frontend/.claude/skills/ 只服务前端包,让 packages/backend/.claude/skills/ 只服务后端包,而不是把所有知识都堆在仓库根目录。

这时 Skill 就不只是“提示词文件”,而是团队知识的分发机制:

  • 个人层的 Skill,固化的是你的工作习惯
  • 项目层的 Skill,固化的是团队约定
  • 包级 Skill,固化的是模块边界里的局部知识

如果你能把这层想清楚,很多“这个规则到底该放哪”的问题,答案会比只看内容本身更清楚。


六、如果只停在经验层,这篇还差半口气:我后来去翻了源码

前面这些判断,靠经验其实也能总结出来。

但我后来还是不太满足。因为有几个问题如果不看实现,心里总会悬着:

  • description 到底是不是自动触发的关键?
  • allowed-tools 到底只是提示,还是硬限制?
  • paths 到底是真过滤,还是只是写给人看的说明?

我后来去翻了一遍源码,结论是:这些字段比我一开始以为的更“硬”。

1. 为什么触发逻辑主要看 frontmatter,而不是正文

loadSkillsDir.ts 里有一个函数 estimateSkillFrontmatterTokens,注释写得非常直接:

/**
 * Estimates token count for a skill based on frontmatter only
 * (name, description, whenToUse) since full content is only loaded on invocation.
 */
export function estimateSkillFrontmatterTokens(skill: Command): number {
  const frontmatterText = [skill.name, skill.description, skill.whenToUse]
    .filter(Boolean)
    .join(' ')
  return roughTokenCountEstimation(frontmatterText)
}

这段代码背后的意思非常重要。

Claude Code 启动时,主要只把每个 Skill 的 frontmatter 信息算进上下文。Skill 正文不是一开始就全量塞进去,而是在你真正触发它的时候才加载。

这直接解释了两件事。

第一,Claude 不是先把你整篇 Skill 读完再判断要不要触发,它先看的就是前面这几行。换句话说,触发效果主要取决于 frontmatter,不取决于正文写得多漂亮。

第二,Skill 多不等于上下文立刻爆炸,因为启动时压进去的不是全文,而是 frontmatter。

源码里保留了 whenToUse 这个概念,但从现在的文档实践看,推荐做法已经更偏向把触发描述直接写进 description。所以对大多数人来说,最稳的策略不是纠结“要不要额外写一个触发字段”,而是把 description 写得具体、可命中、带触发场景。

比如:

description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.

比“代码审查 Skill”这种描述强太多了。

源码注释里其实还暗含了一个很实用的提醒:触发描述不是越长越稳。冗长的 whenToUsedescription 不会线性提高命中率,很多时候只是在白白消耗首轮缓存和注意力。所以对这个字段最好的优化,不是“多写一点”,而是“把真正会命中的词放到前面”。

2. 为什么 allowed-tools 不是建议,而是权限边界

这一点是我看源码之后感受最强的一处。

Skill 执行时,getPromptForCommand 会在返回内容之前把 allowedTools 写进工具权限上下文:

getAppState() {
  const appState = toolUseContext.getAppState()
  return {
    ...appState,
    toolPermissionContext: {
      ...appState.toolPermissionContext,
      alwaysAllowRules: {
        ...appState.toolPermissionContext.alwaysAllowRules,
        command: allowedTools,
      },
    },
  }
}

这说明 allowed-tools 不是“提醒 Claude 尽量这样做”,而是权限层的强制限制。

比如一个 code review Skill 只开放 ReadGrepGlobBash(git diff *),那它就不是“理论上不该写文件”,而是从架构上根本没有写文件的能力Bash(git diff *) 这种写法也不是装饰,它真的只允许 git diff 开头的命令,其他 Bash 调用会被挡住。

这让我对 allowed-tools 的理解完全变了。它不是“不信任模型”,而是最小权限设计。就像你给数据库只读账号只开 SELECT 权限,不是因为你怀疑这账号会作恶,而是因为这个任务本来就不该拥有写权限。

3. 为什么 paths 不是说明文字,而是条件激活机制

源码里,带 paths 的 Skill 在加载时会被单独分流到一个 conditionalSkills Map:

// Separate conditional skills (with paths frontmatter) from unconditional ones
for (const skill of deduplicatedSkills) {
  if (skill.type === 'prompt' && skill.paths && skill.paths.length > 0
      && !activatedConditionalSkillNames.has(skill.name)) {
    newConditionalSkills.push(skill)
  } else {
    unconditionalSkills.push(skill)
  }
}

// Store conditional skills for later activation when matching files are touched
for (const skill of newConditionalSkills) {
  conditionalSkills.set(skill.name, skill)
}

// 最后只返回无条件的 Skill
return unconditionalSkills

这段逻辑的含义是:带 paths 的 Skill,根本不会像普通 Skill 一样直接进入启动时上下文。它会先待在一个“条件激活区”里,只有当你在会话里碰到了匹配路径的文件,它才会被真正激活。

这点对复杂项目非常有价值。

比如你给支付模块写一个 paths: src/payment/** 的 Skill,在你处理用户系统、文章系统、管理后台时,这个 Skill 对 Claude 几乎是隐身的。只有当你真的进入 src/payment/ 相关文件,它才“出现”。

这也是我现在很认同的一种团队实践:不要在根目录堆一个什么都想管的大 Skill 集合,而是让复杂模块在自己的目录附近维护自己的 Skill。

4. 为什么大型项目不该只在根目录维护一套总 Skill

还有一个很容易被忽略,但工程上非常实用的机制:Claude Code 会从当前文件所在目录一路向上寻找 .claude/skills

源码大概是这样:

// Walk up to cwd but NOT including cwd itself
while (currentDir.startsWith(resolvedCwd + pathSep)) {
  const skillDir = join(currentDir, '.claude', 'skills')
  // ...check if exists, then load
  currentDir = dirname(currentDir)
}

// Sort by path depth (deepest first) so skills closer to the file take precedence
return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)

这里最关键的是最后一行:deepest first。也就是说,越靠近当前文件的 Skill,优先级越高。

这意味着你放在 src/auth/.claude/skills/ 里的 Skill,可以自然覆盖根目录下更通用的同名 Skill。对 monorepo 或大仓库来说,这个机制非常好用:

  • packages/api/.claude/skills/ 可以放 API 专属 Skill
  • packages/web/.claude/skills/ 可以放前端专属 Skill
  • 根目录只保留真正的全局规则

如果把上面四点放在一起看,设计 Skill 的顺序其实会变得很清楚:

  • 先把 frontmatter 写准,再去打磨正文步骤
  • 先按最小权限收紧 allowed-tools,再考虑要不要给更多能力
  • 只有模块边界明确时再上 paths,不要为了“高级”硬加
  • 多目录项目优先做“离代码更近”的局部 Skill,而不是维护一个大而全的总模板

5. 为什么长对话里,Skill 不会轻易“失忆”

还有一个很多人会担心的问题:会话一长、上下文一压缩,前面调过的 Skill 会不会就悄悄失效了?

从实现思路看,Claude Code 不是简单把它们扔掉,而是会把最近调用过的 Skill 重新注入压缩后的上下文。工程上你可以把它理解成:Skill 不是“一次触发完就全靠模型自己记住”,而是一个可以被系统再次带回来的工作单元。

当然,这也不是说你可以无限制地把 Skill 写成超长文档。实现上会有保留预算,比如最近调用的 Skill 只会保留前一段核心内容,而不是把所有正文永久塞在上下文里。所以前面那条原则依然成立:把 frontmatter 写准,把正文写短,把真正长的材料拆到 reference 或脚本里。

Skill 运行机制与条件激活


七、不是所有 Skill 都应该让 Claude 自动触发

到这里,其实已经够你写出一个基础可用的 Skill 了。

但如果你真的准备在项目里长期用,接下来有一个问题迟早会遇到:

这个 Skill 到底应该让 Claude 自动触发,还是只能我手动触发?

这背后其实对应两种完全不同的 Skill。

1. 参考型 Skill:给 Claude 补充背景知识

这类 Skill 的作用不是“执行一个任务”,而是“把某个项目知识注入到当前工作里”。

比如 API 设计规范、错误处理约定、数据库命名规则。这些东西你希望 Claude 在写代码、改代码、review 代码时,只要场景合适就自动想起来。

这类 Skill 的特点是:

  • 倾向自动触发
  • 内容会留在主对话上下文里
  • 更像“局部规范”而不是“独立任务”

比如:

---
name: api-conventions
description: API design patterns and conventions for this codebase. Use when writing or reviewing API endpoints.
---

响应格式统一用 { success, data, timestamp }
禁止在 controller 层直接写 SQL,通过 service 层操作
所有异步函数必须有 try-catch,错误统一 throw new AppError()

2. 任务型 Skill:给 Claude 一个要完成的动作

这类 Skill 有明确边界,通常还可能带副作用。

比如 /deploy/send-release-email/prepare-release-notes/migrate-db。这类 Skill 更像一段可执行流程,而不是知识注入。

这类 Skill 的特点是:

  • 通常应该手动触发
  • 有副作用时最好加 disable-model-invocation: true
  • 有风险时再配 context: fork

比如:

---
name: deploy
description: Deploy the application to production. Manual trigger only.
context: fork
disable-model-invocation: true
allowed-tools:
  - Bash(npm run test)
  - Bash(npm run build)
  - Bash(git push *)
---

1. 跑完整测试:npm run test
2. 确认测试全绿后构建:npm run build
3. 推送到部署分支
4. 等待 CI 完成并检查健康状态

这里的关键不是字段多了,而是触发权变了。

你真正要想清楚的问题是:

这件事我愿不愿意让 Claude 自己判断“现在该触发了”?

如果答案是“不愿意”,那就不要让它自动触发。

3. disable-model-invocation: trueuser-invocable: false 不是一回事

这是一个很容易混淆,但又非常关键的区别。

默认情况下,你和 Claude 都可以调用一个 Skill。你可以手动 /skill-name,Claude 也可以在觉得合适时自动加载它。

但很多人会把下面两个字段混为一谈:

  • disable-model-invocation: true
  • user-invocable: false

它们看起来都像“限制调用”,其实限制的是两件完全不同的事。

disable-model-invocation: true 的意思是:Claude 不能自己触发,只有你能手动触发。 这类 Skill 适合部署、发版、发邮件、推送消息这类你必须自己掌握时机的动作。更重要的是,它还会把这个 Skill 的描述从 Claude 的常驻上下文里拿掉,平时的上下文成本直接归零,只有你手动调用时才完整加载。

user-invocable: false 的意思则是:你不能从 / 菜单里把它当命令来点,但 Claude 仍然可以在合适时自动用它。 这类 Skill 更适合背景知识,比如老系统架构、内部缩写、遗留约定。这些东西你希望 Claude 在相关任务里自动想起来,但你并不需要一个显眼的 /legacy-context 命令天天挂在菜单里。

所以更准确的判断应该是:

  • 有副作用、要你亲自控制时机:disable-model-invocation: true
  • 只是背景知识、不适合被人手动当命令点:user-invocable: false
  • 想限制 Claude 到底能不能调用某些 Skill:去配权限规则,而不是只盯菜单显示

这组区别值得写进脑子里,因为它直接决定了 Skill 的“触发权”到底属于谁。

参考型 Skill 与任务型 Skill


八、Skill 的天花板:从静态模板到可执行能力

前面几节讲的,主要还是“怎么把经验写成一个好模板”。但如果 Skill 只能放静态文字,它的上限其实并不高。

真实项目里,很多任务依赖的是实时信息:当前 PR 的 diff、评论区讨论、今天的测试结果、最新 schema、CI 状态。你当然可以在 Skill 里写“先去看这些东西”,但这样一来,最关键的一步又变回 Claude 自己先兜一圈去找。

Claude Code 里真正更有意思的地方是:Skill 不只是 prompt 模板,它还可以在触发瞬间先准备上下文。

1. 动态注入:先拿真实数据,再交给 Claude

比如你要做一个 PR 总结 Skill,不一定要让 Claude 先自己去猜该看哪些信息,你可以直接让 Skill 触发时先把它们准备好:

---
name: pr-summary
description: Summarize the current pull request. Use when asked to review or summarize a PR.
context: fork
allowed-tools:
  - Bash(gh *)
---

## 当前 PR 信息
- diff:!`gh pr diff`
- 评论区讨论:!`gh pr view --comments`
- 涉及文件:!`gh pr diff --name-only`

## 你的任务
基于以上真实数据,总结这个 PR 做了什么、为什么改、有哪些潜在风险。

这个 Skill 真正触发时,前面的命令会先执行,输出直接注入到 prompt 里。Claude 拿到的不是“请你去看看 PR”这种模糊要求,而是已经准备好的真实上下文。

这也是为什么前面说 scripts/ 不是装饰。如果一个 Skill 需要先查状态、先取数、先做一轮预处理,那它就不再只是“告诉 Claude 怎么做”,而是在执行前把材料也一起备好了。更复杂一点时,你完全可以把逻辑放进 scripts/,再通过 CLAUDE_SKILL_DIR 去调用目录里的脚本,让 Skill 触发时先跑一轮取数或整理。

2. 这件事为什么重要:它决定了 Skill 的上限

一旦你理解了动态注入这层能力,就会发现 Skill 的上限根本不只是“几行 prompt”。

它可以同时承担三件事:

  • 定义触发条件
  • 限制工具权限
  • 在执行前准备实时上下文

换句话说,Skill 不是只能做静态模板,它完全可以长成一个带入口、带约束、还能主动取数的局部能力单元。

不要只把 Skill 当成“写给模型的一段话”,而要把它当成“一个局部工作流的入口”。

3. 从架构位置看,Skill 不是 Tool,也不是 Agent

如果再往上抽一层,我现在对 Skill 的理解是:

  • Tool 是原子能力
  • Skill 是任务知识和操作规约
  • Agent 是执行与编排单元

这个分层不一定是官方唯一表述,但作为工程心智模型非常有用。因为一旦把 Skill 放到 Tool 和 Agent 中间去理解,很多判断都会顺下来:

  • 该写成脚本的,别硬写进 Skill 正文
  • 该放进 CLAUDE.md 的全局规则,别误沉淀成任务 Skill
  • 该拆给 SubAgent 的复杂协作,别硬让一个 Skill 扛完

Skill 真正做的事,不是替代 Tool,也不是替代 Agent,而是把“什么场景下、按什么步骤、调用哪些能力”组织成可复用的任务单元。


九、真正让 Skill 变靠谱的,不是写出来,而是验证出来

这是我觉得官方 best practices 里最容易被忽略、但最能拉开水平差距的一点。

很多人写 Skill 的方式是:先凭感觉写一版,再上项目里试试。这样当然也能跑,但它很容易陷进一种错觉里: 你以为自己在“优化 Skill”,其实只是一直在补想象中的问题。

官方更推荐的思路其实更工程化:

先准备评测样例,再写 Skill。

最轻量、也最实用的做法,是先找出三个真实场景:

  • 一个 Claude 原本就能做得不错的
  • 一个 Claude 容易漏步骤的
  • 一个 Claude 容易理解偏、触发错或者输出不稳的

先不用 Skill 跑一遍,记录基线。看它具体错在哪:

  • 是没想到要用这个 Skill
  • 是触发了,但没读对参考资料
  • 是步骤顺序不稳
  • 是输出格式飘了
  • 是该脚本处理的确定性工作,被它自己“猜”过去了

然后再写最小版本的 Skill,只补刚才暴露出来的那几个缺口,而不是一上来把所有可能性都写满。

把这个过程压缩成一句话,其实就是:

  1. 先找失败样例
  2. 再写最小 Skill
  3. 再看它是不是真的修掉了失败样例

如果三轮下来都没有明显提升,那大概率不是 Skill 写得不够多,而是这个问题根本不该沉淀成 Skill。

不要只看结果,还要看 Claude 是怎么“导航”这个 Skill 的

只看最终结果对不对还不够,更重要的是看 Claude 在过程中到底是怎么用这个 Skill 的。

官方专门建议观察 Claude 实际怎么使用 Skill,而不是只看最终结果对不对。比如:

  • 它是不是总在错误顺序里读文件
  • 它是不是老是忽略某个引用文件
  • 它是不是每次都反复读同一个 reference,那这部分也许该往 SKILL.md 主体里提
  • 某个 examples 文件是不是从来没被读过,那它可能根本没价值,或者信号太弱

这些观察特别重要,因为它能直接反推出信息架构是不是合理。

我现在会把一个 Skill 是否成熟,简单看成四个问题:

  • 会不会在该触发的时候触发
  • 触发后会不会按预期去读材料
  • 执行过程会不会漏掉关键步骤
  • 输出结果能不能稳定复现

如果你团队里会混用不同模型,官方还建议至少跨你计划使用的模型测一遍。不是因为所有模型都得兼容,而是因为有些 Skill 对提示强度和结构依赖更高,换模型后会暴露出你原来没看到的问题。

说到底,Skill 的成熟度,不是靠“我觉得写得挺全”来判断,而是靠一组真实任务能不能稳定跑通来判断。


十、写 Skill 时,最常见的四种设计模式,以及最容易踩的反模式

这里先说清楚:下面这四种名字,不是官方文档逐字给出的固定术语,而是我结合 Anthropic 官方 best practices 做的工程化归纳。

但它们确实能覆盖大多数项目里真正会遇到的 Skill 设计问题。

1. 模板驱动模式:解决“输出不稳定”

这类 Skill 的核心不是让 Claude 更会想,而是让它更稳定地按结构输出。

适合:

  • 周报
  • PR 描述
  • 事故复盘
  • 评审报告

它的关键不是“给一个模板”,而是把模板当成输出接口。模板负责格式,SKILL.md 负责路由和规则。模板里不要塞判断逻辑,也不要把一个模板写成一套小程序。

如果一个模板已经长到一百多行,而且开始出现大量条件分支,通常不是你模板写得认真,而是职责已经混了。

2. 脚本增强模式:解决“结果不稳定”

这类 Skill 的核心是:确定性的事交给脚本,不要交给模型猜。

适合:

  • 指标统计
  • CSV 解析
  • 正则匹配
  • Git / PR / CI 状态抓取
  • 需要预处理的上下文准备

官方对这件事的说法很直白:solve, don't punt。能脚本算出来的,就别只写一句“请 Claude 自行分析”。

这类 Skill 真正提高的,不是文风,而是可靠性。你把概率型推理替换成确定性执行,整个 Skill 的下限会明显抬高。

3. 知识分层模式:解决“上下文过载”

这是官方反复强调的 progressive disclosure 思路。

也就是:SKILL.md 只做入口和导航,把大块知识拆进 reference/examples/forms/ 这类文件里,按需读取,而不是一次灌满。

这类模式适合:

  • 领域知识很多的 Skill
  • 不同子领域差异很大的 Skill
  • 需要高级用法、边界情况、案例库的 Skill

它的关键不是“拆文件”,而是“拆得有层次”。官方明确不建议深层嵌套引用。最稳的做法是所有重要材料都从 SKILL.md 一层直达,别让 Claude 从 advanced.md 再跳 details.md 才看到真正关键信息。

4. 工具隔离模式:解决“能力边界失控”

这类模式最容易被低估。

很多人写 Skill 时只关注“让它能做事”,但真正稳定的 Skill 往往同样重视“让它不能乱做事”。

适合:

  • 部署
  • 发版
  • 写数据库迁移
  • 调外部系统
  • 会改文件、发消息、推远端的任务

这一类 Skill 的核心组合通常是:

  • allowed-tools 收到最小
  • 必要时配 context: fork
  • 不希望自动触发时加 disable-model-invocation: true

它不是在限制 Claude 的创造力,而是在设计这个能力包的安全边界。

最常见的反模式

如果把前面四种模式反过来看,最常见的坑基本也就集中在下面这些地方:

  • 一个 Skill 管太多事,什么都想覆盖,最后什么都不够准
  • description 写得很空,只写“处理文档”“帮助分析”这种谁都能套上的话
  • 一上来给一堆方案,不给默认路径,让 Claude 自己从五六种做法里摇摆
  • 模板里写判断逻辑,或者把本该脚本做的事丢给模型推理
  • reference 层级太深,真正关键信息藏在第二跳、第三跳文件里
  • 把会过期的信息硬写进 Skill,比如时间敏感规则、旧接口切换说明
  • 默认假设工具和依赖都已经装好,结果一跑就断
  • 在路径里写 Windows 反斜杠,跨环境直接出问题

你会发现,所谓反模式,本质上就是一句话:

该约束的地方没约束,该拆开的地方没拆开,该交给脚本的地方还在让模型猜。


十一、完整案例:把一个通用 code review 模板,提炼成你项目真正需要的 Skill

上面说了这么多抽象原则,不如走一遍完整例子。

假设你们团队每周都做后端代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

第一步:先确认痛点到底是什么

这一步别着急写模板,先把“你们到底在反复出什么问题”说清楚。

很多团队的问题不是“没有 code review”,而是每次 review 的注意力都被分散了。真正高频出错的点,永远是那几类项目特有的约束。

所以要沉淀的不是“代码审查”这四个字,而是你们团队在代码审查里最容易漏掉的那几类检查。

第二步:从公开模板里提取真正有用的部分

这时候公开模板就有用了,但它的用途不是直接上生产,而是当素材库。

假设你找到一个 50 行的通用 code review 模板。你真正该提取的,可能只有下面这几类东西:

  • 逻辑正确性,尤其是异步操作
  • 项目约定的遵守,比如 AppError、错误处理模式
  • 数据库相关的风险,比如 N+1、索引、查询范围
  • 改动范围是否聚焦,不要顺手改不相干文件

剩下那些跟你们项目关系不大的部分,就应该果断删掉。

第三步:把它压缩成一个真正能用的 Skill

最后落地出来的 Skill,应该更像这样:

---
name: code-review
description: Review TypeScript backend code before merging. Use when asked to review code, check a PR, or verify implementation before committing.
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
---

# Code Review Skill

## Steps
1. 读 $ARGUMENTS 的改动 diff,确认改动是否只涉及这个 PR 的范围(不要顺手改无关文件)
2. 检查错误处理:所有 throw 都必须是 throw new AppError(),不能 throw new Error()
3. 检查异步操作:Promise 链是否有遗漏的 await,错误是否被正确 catch
4. 检查数据库查询:是否有 SELECT * 的懒惰写法,是否明显的 N+1 查询,关键查询是否 explain 过

## Output Format
Issues found (Critical → Warning → Info):

🔴 **Line 45**: Missing `.select()` in Prisma query - this will fetch unnecessary columns
🟡 **Line 67**: Potential N+1: loop inside `posts.map()` should use `Promise.all()`**No AppError violations** — all errors properly handled

Summary: 1 critical issue to fix before merge.

## Caveats
- 不审查 UI 层代码(只关心后端逻辑)
- 不关注代码风格(那是 prettier 的事)
- 如果一个改动涉及多个不相干功能,分别提交 PR 再 review

你会发现,到这一步之后,Skill 就不再是“通用模板的中文版”了,而是你们项目真正有用的一个局部工作流。

50 行公开模板,最后可能只剩下 4 个真正属于你项目的核心关注点。但恰恰是这 4 个点,才决定它到底值不值得用。

第四步:在真实使用里继续迭代

Skill 从来不是一次写完的。

比如你用了两周之后,又发现一个常见问题:改了数据库 schema,但忘记更新 Prisma 类型。那就把它加进去:

4.5. 检查 Prisma 类型:如果改了数据库,Prisma schema 和生成的 types 是否都已更新

这时候你会发现,Skill 真正的价值不是“第一次写出来”,而是在真实工作里被持续打磨。


十二、Skill 的维护节奏,比第一次写出来更重要

Skill 不是写好就扔。

如果你写完之后三个月不看,它很快就会从“项目经验”重新退化成“历史遗留文档”。

我更推荐一个更贴近实际的轻量节奏:

前一两周
高频使用,快速迭代。每次用完就问自己三个问题:步骤是不是太复杂?输出是不是太啰嗦?有没有漏掉今天刚踩到的新坑?

稳定之后每周一次
回顾一次。看看最近有没有经常被遗漏的步骤,有没有新的痛点需要加入。对 Skill 这种高频小迭代的东西来说,按周看通常比按月看更合适。

每个月做一次清理
把已经不再是问题的注意事项删掉,把那些已经变成全局共识的规则移进 CLAUDE.md。这一步的重点不是加内容,而是防止 Skill 越写越胖。

Skill 应该越用越精炼,而不是越写越臃肿。


十三、一个特别反直觉,但很重要的经验:第一个 Skill,故意别写最重要的任务

这条我非常想单独拿出来讲。

因为很多人第一次沉淀 Skill,会本能地想挑一个最关键的任务,比如“生产环境发布前检查”“数据库迁移前审查”“支付流程改动 review”。

但工程上更稳的做法,其实正好相反。

大多数人写的第一个 Skill,质量都不会太高。触发条件偏模糊,步骤偏啰嗦,输出格式也不够稳定。这很正常,因为你第一次做这件事时,对“什么是好的 Skill”还没有直觉。

如果你一开始就把它用在最关键的任务上,一旦写得不够好,伤害会非常直接:要么关键场合出问题,要么你从此对这套机制失去信心。

更好的策略是:先拿一个重要程度中等、容错率比较高的任务练手。

比如:

  • 写周报
  • 生成 PR 描述
  • 做一次常规 code review

先跑两周,迭代两三次,等你对 Skill 的节奏有感觉了,再去沉淀真正关键的流程。

第一个 Skill 的目的,不是直接解决最大的问题,而是让你学会怎么写 Skill。


十四、如果你今天就想开始,可以直接做这三个动作

别先想着搭一整套系统,直接从最小动作开始:

任务一: 列出你最近一个月里重复做过三次以上的任务。用第一节的判断法(对所有任务成立?→ CLAUDE.md;只对某类任务成立?→ Skill;只对这次成立?→ Prompt),确认你要处理的是 Skill 还是 CLAUDE.md 的事。

任务二: 为这个任务写一个 Skill。先想清楚四个问题(什么时候用、按什么顺序、输出长什么样、什么时候不适用),然后包装成 SKILL.md。目标是精炼——大多数好 Skill 的有效指令不超过 10 行。记住:第一次的目的是练手,不是写出完美的 Skill。

任务三: 选三个你最近真的遇到过的场景,先不用 Skill 跑一遍,再用 Skill 跑一遍,对比它到底有没有少漏步骤、少返工、少补充解释。然后在 SKILL.md 里补一行今天发现的问题(哪个步骤漏了,或者哪个注意事项需要加)。这就是 Skill 的维护节奏,也是最轻量的评测方法。

真正好的 Skill,几乎都不是第一次就写对的,而是在实际使用里慢慢长出来的。


下篇预告

第 06 篇:Sub-agents 实战——什么时候应该拆任务,怎么设计子任务边界

单个 Claude 实例有上下文上限,复杂任务拆成多个子任务让 Sub-agents 并行处理,理论上能大幅提速。但什么时候值得拆?拆错了会有什么代价?下一篇会拆开 Sub-agents 的真实适用场景,以及最常见的过度设计陷阱。


写在最后

公开 Skill 模板当然有用,但它的价值更像脚手架,而不是成品。

你不需要一个很复杂的 Skill 系统。你需要的,往往只是把团队最容易反复犯错的那几件事提前写下来,让 Claude 每次都替你盯住。

如果你现在想动手,就直接做这三步:列出最近一个月里重复做过三次以上的任务;用"三问法"确认它该放 CLAUDE.mdSkill 还是 Prompt;先写一个只有 5 到 10 行的版本,实际用一次,再立刻改第一轮。不要追求第一版完美——Skill 的价值,从来不是"写出来的那一刻",而是"它开始帮你减少重复错误的那一天"。

评论区想聊一个问题:你现在最卡的,到底是"写不出规则",还是"没分清哪些该放 CLAUDE.md、哪些该做成 Skill"? 这两个问题看起来很像,但解法完全不同。

觉得这篇有帮助,点个赞让更多工程师看到 👍


AI Coding 系列持续更新。用别人的 Skill 模板是起点,不是终点。真正管用的 Skill,只有你自己的项目才能提炼出来。

连载04-最重要的Skill---一起吃透 Claude Code,告别 AI coding 迷茫

2026年4月10日 19:48

screenshot-20260410-194956.png

别再直接 Fork 别人的 Claude Skill:公开模板只是原材料,项目规则才是成品

AI Coding 系列第 05 篇 · 核心工具

我第一次批量导入公开 Skill 模板的时候,是真的以为自己走了捷径。

GitHub 上一堆 star 很高的仓库,code review、需求分析、文档编写、调研、拆任务,看起来什么都有。我当时的想法特别简单:既然别人已经把常见工作流整理好了,我直接 fork 一份,全量导入,不就能让 Claude 立刻更稳、更懂项目吗?

结果用了几天,我反而越来越不放心。

它每次都输出得很像那么回事。格式完整,措辞专业,检查项也不少。可真正让我在项目里反复吃亏的那几件事,它一次都没替我盯住。异步链里是不是又漏了 await,这次 migration 有没有回滚方案,新同学是不是又顺手写了 throw new Error(),数据库 schema 改了之后 Prisma 类型是不是也一起更新了。

它会提醒一堆“大家普遍都应该注意”的东西,却不懂“我们团队到底最怕什么”。

后来我才慢慢明白,问题不是 Skill 机制不好,而是我导入的根本不是自己的 Skill,只是别人整理好的经验。

这些经验当然有用,但它们解决的是共性问题,不会天然长成你项目里的“肌肉记忆”。

这篇文章就讲一件事:

怎么把公开模板当原材料,而不是成品;怎么从自己的项目里,提炼出一个真正会被反复复用、而且越用越准的 Skill。

如果你只想先记住一句话,那就是:

公开 Skill 模板是素材库,不是最终产品。


先给你一个判断框架

为了后面不绕,我先把最核心的判断放前面:

  • 对所有任务都生效的规则,放 CLAUDE.md
  • 只对某一类任务生效的规则,做成 Skill
  • 只对这一次任务有效的约束,写进 Prompt
  • 只有“输入相对稳定、输出有共同模式、而且容易漏步骤”的任务,才值得沉淀成 Skill
  • 第一个 Skill 不要选最关键的任务,先拿中等风险任务练手

如果你现在就卡在“这条到底该写哪”,后面大部分内容其实都可以用这五条往回推。


一、为什么很多公开 Skill 模板,一开始觉得香,后来却越用越别扭

我现在反而会对“看起来很全”的公开 Skill 模板保持一点警惕。

不是因为它们没用,而是因为它们太容易制造一种错觉:好像什么都覆盖到了,但真正最重要的东西其实没进去。

公开模板最常见的问题,不是方向错,而是下面这三种。

1. 太宽泛

它什么都管一点,但什么都不够深。

它会告诉你“注意异常处理”“注意性能”“注意安全”,这些当然没错。但这些话本身不构成你项目里的工作流。它不知道你们统一用的是 AppError,不知道你们数据库变更必须检查回滚,也不知道你们哪几个目录历史包袱最重。

2. 太嘈杂

50 行模板里,真正有价值的可能只有 5 行。

剩下的 45 行不是完全没用,而是在和那 5 行争夺 Claude 的注意力。对于 agent 来说,规则不是越多越强。很多时候,8 行写透项目约束的 Skill,比 50 行“样样都提一点”的模板更有用。

3. 太不像你的项目

这点才是最致命的。

公开模板知道“大家普遍应该注意什么”,但不知道“你们团队反复死在哪些地方”。而真正有价值的 Skill,恰恰应该把那些项目特有、团队高频踩坑的东西固化下来。

说得更直白一点:你把一个新同事扔进团队,给他一份行业通用培训材料,当然比什么都不给强;但如果你不告诉他“我们团队最容易出错的是哪三件事”,他依然干不好你最在意的活。

所以正确姿势不是“找一个最全的模板直接用”,而是:

先借鉴,再裁剪,最后只留下真正属于你项目的那几条。


二、先把 Prompt、CLAUDE.md、Skill 这三件事分清楚

很多人不是不会写 Skill,而是一开始就把这三件事混在一起了。

判断方法其实很简单,只问一个问题的三个变体:

  • 这个要求对所有任务都成立吗?如果是,放 CLAUDE.md
  • 这个要求只对某一类任务成立吗?如果是,做成 Skill
  • 这个要求只对这一次成立吗?如果是,写进 Prompt

举几个特别典型的例子:

“所有 throw 必须是 AppError
这是全局规则。不管你是在写新功能、修 bug,还是做重构,都要遵守。它应该进 CLAUDE.md

“代码审查时按固定顺序检查数据库、异步和错误处理”
这只在 code review 这种任务里才触发,它不是全局规则,而是任务模板,所以应该做成 Skill

“这次先只分析原因,不要动代码”
这只对当前这次任务有效,应该写进 Prompt

最容易搞混的是 CLAUDE.mdSkill。它们都能约束 Claude 的行为,但本质完全不同:

  • CLAUDE.md 是永远生效的规则
  • Skill 是遇到对应任务才触发的模板

如果要打个比方:

  • CLAUDE.md 是交通规则
  • Skill 是导航路线
  • Prompt 是你这次上车前临时交代的一句话

这三层一旦分清楚,后面很多混乱都会自动消失。


三、什么时候一个任务值得被沉淀成 Skill

不是所有重复任务都值得沉淀。

我现在给自己的标准其实很克制,就一句话:

同一类任务做了三次以上,而且每次都要重新给 Claude 解释背景。

反过来说,如果某个任务每次背景和目的都完全不同,就不值得沉淀。比如“写文档”这个动作本身很常见,但公司文档、API 文档、用户手册的写法完全不同,它们应该是三个不同的 Skill,而不是一个叫“写文档”的通用模板。

在真正开始写之前,我会先做三个检查。

1. 输入是否稳定

“根据 Figma 设计稿生成 React 组件”这种任务,输入格式相对稳定,比较适合沉淀。

“根据 SQL 查询结果生成图表”这种任务,每次数据格式和图表类型都可能差很多,Skill 会很难写得稳。

2. 输出是否有共同模式

“写 Pull Request 描述”很适合,因为它天然就有固定框架:改了什么、为什么改、怎么测试。

但“和 AI 讨论技术方案”这种任务,每次深度、重点、结论都不同,就不太适合硬沉淀成一个模板。

3. 有没有容易漏掉的关键步骤

最值得沉淀成 Skill 的任务,通常不是“最复杂”的任务,而是那些不特别提醒就容易漏一步的任务。

Skill 最有价值的地方,不是让 Claude 变得更聪明,而是把你每次最容易忘的检查项,固化成默认动作。

所以一个任务如果同时满足下面三点:

  • 输入相对稳定
  • 输出有共同模式
  • 总有一两步容易漏

它就很值得沉淀成 Skill。


四、一个真正好用的 Skill,内容层通常只需要四个部分

很多人一开始会把 Skill 写得很重,像在写规范文档。但实际用起来之后你会发现,真正好用的 Skill 通常很短。

它一般只需要四个部分。

1. 触发条件

什么时候用,一句话说清楚。

❌ 代码审查
✅ 当我提交 PR 前,检查我的实现是否符合项目约定

2. 执行步骤

按什么顺序做,列出来。尽量不要超过五步。

1. 读完整个改动的 diff
2. 检查是否用了禁用的库或模式
3. 检查异步操作的错误处理
4. 检查是否有 SQL 注入的风险
5. 给出修改建议

3. 输出格式

不要写“请清晰输出”。这种话几乎没有约束力。直接给模板。

❌ 用清晰的格式列出所有问题

✅ 给个模板:
Found 3 issues:
🔴 Critical: ...
🟡 Warning: ...
✅ Suggestion: ...

4. 注意事项

说清楚边界。什么情况不适用,有哪些常见陷阱。

- 不适用于新增功能的初始实现,只适用于 PR 前的最终检查
- 不关注 UI 层细节
- 改动超过 500 行,先拆成多个 Skill 请求

Skill 不是规范手册,更不是把所有经验一次性塞进去。它本质上是一个高频任务的最小可执行模板。


五、真正落到 SKILL.md 文件层,哪些字段最值得你花心思

讲完“内容怎么提炼”,还得讲“文件怎么写”。

很多人第一次写 SKILL.md 会卡在另一个地方:字段太多,不知道哪些真有用,哪些只是“看起来高级”。

一个完整的 SKILL.md,通常会长这样:

---
name: code-review
description: 提交 PR 前的代码审查
when_to_use: 当用户要求 review 代码或提交 PR 前检查时
allowed-tools:
  - Read
  - Grep
  - Glob
  - Bash(git diff *)
argument-hint: "[PR 分支名或文件路径]"
arguments:
  - target
---

# Code Review

## 步骤
1. 读取 ${target} 的改动 diff
2. 检查错误处理:所有 throw 必须是 throw new AppError()
3. 检查异步操作:Promise 链是否有遗漏的 await
4. 检查数据库查询:是否有 N+1 问题

## 输出格式
🔴 Critical: ...
🟡 Warning: ...
✅ 通过: ...

这里最值得你认真写的,其实是下面几个字段。

name
名字别太抽象。要让人一眼知道它是做什么的。

description
一句话说清这个 Skill 的用途,不要写成空泛口号。

when_to_use
这是最容易被低估的字段之一。它不是装饰,它直接影响 Claude 在什么场景下会想到这个 Skill。

allowed-tools
它决定这个 Skill 具备哪些能力。这个字段后面我会在源码部分展开讲,因为它比很多人想象的更“硬”。

arguments
让 Skill 接受参数,比如目标文件、目录、分支名。${target} 会在正文里被替换成你传进去的实际值。

还有几个很好用,但不是每次都要上的字段。

argument-hint
告诉调用者这个 Skill 期待什么参数。

model: haiku
简单任务可以指定更轻量的模型,直接省成本。像格式化、重命名、简单改写这类工作,很多时候没必要上更重的模型。

paths
让 Skill 只在某些路径下激活。适合模块边界明确的项目。

context: fork
高风险操作放进独立上下文,避免污染主会话。

大多数 Skill 根本不需要把字段填满。真正实用的思路不是“功能全”,而是“正好够用”。

如果一个 Skill 只是做常规代码审查,前四五个字段通常就够了。只有当你真的遇到参数化、模块隔离、上下文隔离这些需求时,再往上加。


六、如果只停在经验层,这篇其实还差半口气:我后来去翻了源码

前面这些判断,靠经验其实也能总结出来。

但我后来还是不太满足。因为有几个问题如果不看实现,心里总会悬着:

  • when_to_use 到底是不是自动触发的关键?
  • allowed-tools 到底只是提示,还是硬限制?
  • paths 到底是真过滤,还是只是写给人看的说明?

我后来去翻了一遍源码,结论是:这些字段比我一开始以为的更“硬”。

1. Claude 只在启动时读 frontmatter,Skill 正文是懒加载的

loadSkillsDir.ts 里有一个函数 estimateSkillFrontmatterTokens,注释写得非常直接:

/**
 * Estimates token count for a skill based on frontmatter only
 * (name, description, whenToUse) since full content is only loaded on invocation.
 */
export function estimateSkillFrontmatterTokens(skill: Command): number {
  const frontmatterText = [skill.name, skill.description, skill.whenToUse]
    .filter(Boolean)
    .join(' ')
  return roughTokenCountEstimation(frontmatterText)
}

这段代码背后的意思非常重要。

Claude Code 启动时,主要只把每个 Skill 的 namedescriptionwhen_to_use 这些 frontmatter 信息算进上下文。Skill 正文不是一开始就全量塞进去,而是在你真正触发它的时候才加载。

这直接解释了两件事。

第一,when_to_use 写得越具体,自动命中的效果就越稳定。Claude 不是先把你整篇 Skill 读完再判断要不要触发,它先看的就是前面这几行。

第二,你有十个 Skill 还是三个 Skill,对启动时上下文的占用差距没你想的那么大。真正的成本在触发时才发生。

所以 when_to_use 不能写成“代码相关任务时使用”这种空话。它要写成“当用户要求 review TypeScript 后端代码或提交 PR 前做最终检查时”这种具体到能命中的描述。

这也是为什么我现在越来越重视 frontmatter。以前我会把心思都放在正文步骤上,后来才发现,前面几行写虚了,后面写得再好都不一定有机会被用上。

2. allowed-tools 是系统层权限,不是给 Claude 的礼貌性建议

这一点是我看源码之后感受最强的一处。

Skill 执行时,getPromptForCommand 会在返回内容之前把 allowedTools 写进工具权限上下文:

getAppState() {
  const appState = toolUseContext.getAppState()
  return {
    ...appState,
    toolPermissionContext: {
      ...appState.toolPermissionContext,
      alwaysAllowRules: {
        ...appState.toolPermissionContext.alwaysAllowRules,
        command: allowedTools,
      },
    },
  }
}

这说明 allowed-tools 不是“提醒 Claude 尽量这样做”,而是权限层的强制限制。

比如一个 code review Skill 只开放 ReadGrepGlobBash(git diff *),那它就不是“理论上不该写文件”,而是从架构上根本没有写文件的能力Bash(git diff *) 这种写法也不是装饰,它真的只允许 git diff 开头的命令,其他 Bash 调用会被挡住。

这让我对 allowed-tools 的理解完全变了。它不是“不信任模型”,而是最小权限设计。就像你给数据库只读账号只开 SELECT 权限,不是因为你怀疑这账号会作恶,而是因为这个任务本来就不该拥有写权限。

3. paths 不是文档字段,它会把 Skill 放进条件激活区

这一点也比表面上看起来更硬。

源码里,带 paths 的 Skill 在加载时会被单独分流到一个 conditionalSkills Map:

// Separate conditional skills (with paths frontmatter) from unconditional ones
for (const skill of deduplicatedSkills) {
  if (skill.type === 'prompt' && skill.paths && skill.paths.length > 0
      && !activatedConditionalSkillNames.has(skill.name)) {
    newConditionalSkills.push(skill)
  } else {
    unconditionalSkills.push(skill)
  }
}

// Store conditional skills for later activation when matching files are touched
for (const skill of newConditionalSkills) {
  conditionalSkills.set(skill.name, skill)
}

// 最后只返回无条件的 Skill
return unconditionalSkills

这段逻辑的含义是:带 paths 的 Skill,根本不会像普通 Skill 一样直接进入启动时上下文。它会先待在一个“条件激活区”里,只有当你在会话里碰到了匹配路径的文件,它才会被真正激活。

这点对复杂项目非常有价值。

比如你给支付模块写一个 paths: src/payment/** 的 Skill,在你处理用户系统、文章系统、管理后台时,这个 Skill 对 Claude 几乎是隐身的。只有当你真的进入 src/payment/ 相关文件,它才“出现”。

这也是我现在很认同的一种团队实践:不要在根目录堆一个什么都想管的大 Skill 集合,而是让复杂模块在自己的目录附近维护自己的 Skill。

4. Skill 发现是沿目录向上找的,而且离文件越近优先级越高

还有一个很容易被忽略,但工程上非常实用的机制:Claude Code 会从当前文件所在目录一路向上寻找 .claude/skills

源码大概是这样:

// Walk up to cwd but NOT including cwd itself
while (currentDir.startsWith(resolvedCwd + pathSep)) {
  const skillDir = join(currentDir, '.claude', 'skills')
  // ...check if exists, then load
  currentDir = dirname(currentDir)
}

// Sort by path depth (deepest first) so skills closer to the file take precedence
return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)

这里最关键的是最后一行:deepest first。也就是说,越靠近当前文件的 Skill,优先级越高。

这意味着你放在 src/auth/.claude/skills/ 里的 Skill,可以自然覆盖根目录下更通用的同名 Skill。对 monorepo 或大仓库来说,这个机制非常好用:

  • packages/api/.claude/skills/ 可以放 API 专属 Skill
  • packages/web/.claude/skills/ 可以放前端专属 Skill
  • 根目录只保留真正的全局规则

如果把上面四点放在一起看,设计 Skill 的顺序其实会变得很清楚:

  • 先把 frontmatter 写准,再去打磨正文步骤
  • 先按最小权限收紧 allowed-tools,再考虑要不要给更多能力
  • 只有模块边界明确时再上 paths,不要为了“高级”硬加
  • 多目录项目优先做“离代码更近”的局部 Skill,而不是维护一个大而全的总模板

七、完整案例:把一个通用 code review 模板,提炼成你项目真正需要的 Skill

上面说了这么多抽象原则,不如走一遍完整例子。

假设你们团队每周都做后端代码审查,而且总在重复盯这几件事:

  • 有人改一个功能,顺手动了三个不相关模块
  • 新同学不知道项目里统一用 AppError,直接 throw new Error()
  • Promise 链里漏了 await
  • 数据库查询没有索引,或者潜在 N+1 没被看出来

这就是非常典型的“该沉淀 Skill 的信号”。

第一步:先确认痛点到底是什么

这一步别着急写模板,先把“你们到底在反复出什么问题”说清楚。

很多团队的问题不是“没有 code review”,而是每次 review 的注意力都被分散了。真正高频出错的点,永远是那几类项目特有的约束。

所以要沉淀的不是“代码审查”这四个字,而是你们团队在代码审查里最容易漏掉的那几类检查。

第二步:从公开模板里提取真正有用的部分

这时候公开模板就有用了,但它的用途不是直接上生产,而是当素材库。

假设你找到一个 50 行的通用 code review 模板。你真正该提取的,可能只有下面这几类东西:

  • 逻辑正确性,尤其是异步操作
  • 项目约定的遵守,比如 AppError、错误处理模式
  • 数据库相关的风险,比如 N+1、索引、查询范围
  • 改动范围是否聚焦,不要顺手改不相干文件

剩下那些跟你们项目关系不大的部分,就应该果断删掉。

第三步:把它压缩成一个真正能用的 Skill

最后落地出来的 Skill,应该更像这样:

# Code Review Skill

## When to use
在提交 PR 前,请 Claude 做最后的 code review。只用于 TypeScript 后端代码。

## Steps
1. 读 diff,确认改动是否只涉及这个 PR 的范围(不要顺手改无关文件)
2. 检查错误处理:所有 throw 都必须是 throw new AppError(),不能 throw new Error()
3. 检查异步操作:Promise 链是否有遗漏的 await,错误是否被正确 catch
4. 检查数据库查询:是否有 SELECT * 的懒惰写法,是否明显的 N+1 查询,关键查询是否 explain 过

## Output Format
Issues found (Critical → Warning → Info):

🔴 **Line 45**: Missing `.select()` in Prisma query - this will fetch unnecessary columns
🟡 **Line 67**: Potential N+1: loop inside `posts.map()` should use `Promise.all()`**No AppError violations** — all errors properly handled

Summary: 1 critical issue to fix before merge.

## Caveats
- 不审查 UI 层代码(只关心后端逻辑)
- 不关注代码风格(那是 prettier 的事)
- 如果一个改动涉及多个不相关功能,分别提交 PR 再 review

你会发现,到这一步之后,Skill 就不再是“通用模板的中文版”了,而是你们项目真正有用的一个局部工作流。

50 行公开模板,最后可能只剩下 4 个真正属于你项目的核心关注点。但恰恰是这 4 个点,才决定它到底值不值得用。

第四步:在真实使用里继续迭代

Skill 从来不是一次写完的。

比如你用了两周之后,又发现一个常见问题:改了数据库 schema,但忘记更新 Prisma 类型。那就把它加进去:

4.5. 检查 Prisma 类型:如果改了数据库,Prisma schema 和生成的 types 是否都已更新

这时候你会发现,Skill 真正的价值不是“第一次写出来”,而是在真实工作里被持续打磨。


八、Skill 的维护节奏,比第一次写出来更重要

Skill 不是写好就扔。

如果你写完之后三个月不看,它很快就会从“项目经验”重新退化成“历史遗留文档”。

我更推荐一个很轻的维护节奏:

第一个月
高频使用,快速迭代。每次用完就问自己三个问题:步骤是不是太复杂?输出是不是太啰嗦?有没有漏掉今天刚踩到的新坑?

之后每个月
回顾一次。看看最近有没有经常被遗漏的步骤,有没有新的痛点需要加入。

每个季度
系统清理一次。把已经不再是问题的注意事项删掉,把那些已经变成全局共识的规则移进 CLAUDE.md

Skill 应该越用越精炼,而不是越写越臃肿。


九、一个特别反直觉,但很重要的经验:第一个 Skill,故意别写最重要的任务

这条我非常想单独拿出来讲。

因为很多人第一次沉淀 Skill,会本能地想挑一个最关键的任务,比如“生产环境发布前检查”“数据库迁移前审查”“支付流程改动 review”。

但工程上更稳的做法,其实正好相反。

大多数人写的第一个 Skill,质量都不会太高。触发条件偏模糊,步骤偏啰嗦,输出格式也不够稳定。这很正常,因为你第一次做这件事时,对“什么是好的 Skill”还没有直觉。

如果你一开始就把它用在最关键的任务上,一旦写得不够好,伤害会非常直接:要么关键场合出问题,要么你从此对这套机制失去信心。

更好的策略是:先拿一个重要程度中等、容错率比较高的任务练手。

比如:

  • 写周报
  • 生成 PR 描述
  • 做一次常规 code review

先跑两周,迭代两三次,等你对 Skill 的节奏有感觉了,再去沉淀真正关键的流程。

第一个 Skill 的目的,不是直接解决最大的问题,而是让你学会怎么写 Skill。


十、如果你今天就想开始,可以直接做这三个动作

别先想着搭一整套系统,直接从最小动作开始:

任务一:列任务
列出你最近一个月里重复做过三次以上的任务。

任务二:做分类
用“三问判断法”确认它该放进 CLAUDE.mdSkill 还是 Prompt

任务三:先写一个 5 到 10 行版本
先写触发条件、执行步骤、输出格式、注意事项。不要追求完美,先拿去用一次,再立刻改第一轮。

真正好的 Skill,几乎都不是第一次就写对的,而是在实际使用里慢慢长出来的。


下篇预告

第 06 篇:Sub-agents 实战——什么时候应该拆任务,怎么设计子任务边界

单个 Claude 实例有上下文上限,复杂任务拆成多个子任务让 Sub-agents 并行处理,理论上能大幅提速。但什么时候值得拆?拆错了会有什么代价?下一篇会拆开 Sub-agents 的真实适用场景,以及最常见的过度设计陷阱。


写在最后

公开 Skill 模板当然有用,但它的价值更像脚手架,而不是成品。

真正管用的 Skill,不是 star 最多的那个,也不是字段最全的那个,而是最贴近你项目真实工作流的那个。

你不需要一个很复杂的 Skill 系统。

你需要的,往往只是把团队最容易反复犯错的那几件事,提前写下来,让 Claude 每次都替你盯住。

这才是 Skill 真正应该发挥的作用。

如果你已经开始写 Skill 了,我反而建议你先检查一个问题:

你现在最卡住的,到底是“写不出规则”,还是“根本没分清哪些该放 CLAUDE.md、哪些该做成 Skill”?

这两个问题看起来很像,但解法完全不同。


AI Coding 系列持续更新。用别人的 Skill 模板是起点,不是终点。真正管用的 Skill,只有你自己的项目才能提炼出来。

AI全栈入门指南:使用 NestJs 创建第一个后端项目

作者 Moment
2026年4月9日 09:21

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

前两篇把认知铺好了,这一篇进入上手阶段。

如果你是第一次接触 NestJS,最推荐的方式不是手动搭目录,而是直接使用官方 CLI 创建项目。原因很简单,CLI 不只是帮你生成几个文件,它还会顺手把一个标准可运行的项目骨架搭好。这样你第一次接触时,不会一上来就被配置细节打断。

下面的示例统一使用 pnpm。如果你平时用的是 npmyarn,只要把命令替换成自己习惯的包管理器即可。

在终端中执行下面这条命令,就可以创建一个新的 NestJS 项目:

pnpm dlx @nestjs/cli new hello-nest

执行之后,CLI 会提示你选择包管理器。直接选择自己当前项目里最常用的那个就行。如果你平时主要使用 pnpm,这里继续选 pnpm 会更顺手。

这一步完成之后,你会得到一个已经初始化好的项目目录。依赖会自动安装,基础文件也会一并生成,所以不需要你再从零创建入口文件、路由文件和配置文件。

如果你之前没有用过 dlx,可以把它理解成临时拉起来跑一下某个命令行工具,不必先全局安装 @nestjs/cli,用完就走,比较适合第一次体验。

认识默认目录结构

项目创建完成后,先不要急着写代码。更重要的是先看一眼默认目录结构,因为这会直接帮助你理解 NestJS 是怎么组织应用的。

一个刚创建出来的项目,核心目录大致会像下面这样:

20260328094755

第一次看这些文件时,可以先抓最重要的几个:

  • src/main.ts 是应用入口,负责把整个 NestJS 应用启动起来
  • src/app.module.ts 是根模块,用来组织当前应用的基础结构
  • src/app.controller.ts 是控制器,负责接收请求和返回结果
  • src/app.service.ts 是服务层,负责承载具体业务逻辑

你会发现,哪怕只是一个最简单的 Hello World 项目,NestJS 也没有把所有逻辑都塞进一个文件里。它一开始就把入口、模块、控制器、服务拆开了。这正是前面提到的结构约束。

也就是说,NestJS 希望你从第一个项目开始,就用一种更接近真实业务系统的方式来组织代码,而不是先随便写,等项目变大后再重构。

如果你现在只记一句话,可以先记这个:

src/main.ts 负责启动,Module 负责组织,Controller 负责接请求,Service 负责写逻辑。

后面无论项目变得多复杂,这套基础分工都不会变。

启动开发环境

进入项目目录后,就可以把开发环境跑起来了:

cd hello-nest
pnpm run start:dev

这条命令会以开发模式启动项目,并开启监听。也就是说,你修改 src 里的代码后,服务通常会自动重新编译并重启,不需要你每次手动停掉再启动。

项目启动成功后,终端一般会看到类似 Nest application successfully started 的提示。默认情况下,服务会监听 3000 端口。

20260328094827

这时候你可以先用浏览器打开下面这个地址:

http://localhost:3000

如果一切正常,你会看到默认返回的 Hello World!。这说明项目已经跑起来了。

这一步的意义不只是确认环境没问题,更重要的是让你先建立一个非常直接的印象:

一个刚生成出来的 NestJS 项目,本身就是可运行的。

也正因为默认项目能立刻跑起来,后面改代码时,你能很直观地对照改了哪个文件、行为发生了什么变化。

写第一个接口

默认项目已经有一个最基础的接口,只是它的业务非常简单。为了真正理解 NestJS 的组织方式,最好的做法不是新建一堆复杂模块,而是先把这个默认接口改一遍。

先看服务层。把 src/app.service.ts 改成下面这样:

import { Injectable } from "@nestjs/common";

@Injectable()
export class AppService {
  getHello(): { message: string; from: string } {
    return {
      message: "Hello NestJS",
      from: "AppService",
    };
  }
}

这段代码的重点不是返回什么内容,而是让你看到,真正的业务结果是由 AppService 提供的。控制器不直接写死所有内容,而是去调用服务层。

接着修改 src/app.controller.ts

import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get("hello")
  getHello(): { message: string; from: string } {
    return this.appService.getHello();
  }
}

这里有两个点值得第一次接触时特别留意。

第一,@Controller()@Get("hello") 这类装饰器,标的是这个类、这个方法在 HTTP 这一层各自干什么。

第二,控制器通过构造函数拿到 AppService,而不是自己手动 new AppService()。这就是依赖注入最直观的体现。你声明自己需要什么,框架负责把依赖准备好。

改完之后,重新访问下面这个地址:

http://localhost:3000/hello

如果一切正常,你会看到类似下面这样的返回结果:

20260328095005

看到这里,其实你已经完成了自己的第一个 NestJS 接口。虽然它非常简单,但最关键的骨架已经出现了。

从 Hello World 看 NestJS 的基本组织方式

一个最简单的 Hello World 也能看出 NestJS 的分层习惯,请求不会随便落进某个函数,而是按约定往前走。最短路径可以先想成下面五步:

  • 客户端发起请求
  • 路由命中控制器方法
  • 控制器调用服务层
  • 服务层返回业务结果
  • 框架把结果写回客户端

20260401083712

在这五步之上还要叠上两块,根 Module(例如 AppModule)把 ControllerService 登记到同一个模块里,src/main.ts 创建应用实例并监听端口,进程才真正跑起来。接请求、写业务、做装配、拉起监听,是同一条最小链路上的不同环节。

对应到代码里,先记住四个角色就够:

  • Controller 接住请求
  • Service 处理业务
  • Module 把相关角色收进同一个模块
  • src/main.ts 把应用跑起来并监听端口

这和先把逻辑全塞进一个文件再说的写法差别很大,NestJS 从第一个接口就在推你把入口、业务和装配拆开。返回值具体写了哪句文案反而不那么重要,更值得留意的是三件事已经成习惯:入口和业务分开、依赖交给框架装配、结构按角色长而不是按临时想法堆。

把这条轮廓记熟,后面再学模块拆分、参数校验、异常处理、数据库接入,都容易挂回同一套形状里。

小结

第一个 NestJS 项目的重点,不是把服务跑起来本身,而是借这个最小示例看清它的基本骨架。

通过 CLI 创建项目,你拿到的是一套标准初始结构。通过修改默认接口,你能看到 ControllerService、依赖注入和应用入口是如何协同工作的。

如果你现在已经能理解下面这几件事,这一篇的目标就达到了:

  • 如何用 CLI 创建一个 NestJS 项目
  • 默认目录里几个核心文件分别负责什么
  • 怎样启动开发环境并访问默认服务
  • 怎样改出自己的第一个接口
  • 为什么这个最小例子已经体现了 NestJS 的基本组织方式

接下来会从这个最小项目出发,看 Controller 和路由是怎么对应起来的。

SBTI 测试挤崩服务器:一个程序员视角的技术复盘

2026年4月10日 14:01

昨晚你的朋友圈,是不是也被"尤物""吗喽""愤世者"刷屏了?

4月9日晚,一个叫 SBTI 的人格测试突然引爆社交网络。用户蜂拥而至,网站直接崩了——页面打不开、链接失效,大家只能靠截图"云测试"。作者深夜紧急发布新链接,称"做了略微修改,应该不会再崩了"。

作为一个技术人,看到"网站崩了"和"略微修改就不崩了"这两句话,我的职业病就犯了。今天我们不聊测试准不准,只聊:这背后到底发生了什么?如果是你来做,怎么才能扛住这波流量?

一、崩溃现场还原:一个经典的"雷群效应"

SBTI 测试的崩溃,是教科书级别的 Thundering Herd(雷群效应)案例。

简单说就是:一个原本只为"劝朋友戒酒"而做的小项目,突然被几百万人同时访问。这就好比你开了一家只有两张桌子的面馆,突然被美食博主推荐,门口排了两公里的队。

根据公开信息推测,SBTI 最初大概率是这样的架构:

用户浏览器 → 单台服务器(前端 + 后端 + 数据库 all-in-one

这种架构在日常几百、几千 PV 的场景下完全够用。但当朋友圈裂变式传播启动后,并发量可能在几分钟内从个位数飙升到数万甚至数十万级别。单机扛不住,结果就是:

  • 连接池耗尽:服务器能同时处理的请求数是有限的,超出后新请求直接被拒绝
  • 带宽打满:测试页面包含图片、样式、脚本,每个用户加载一次就消耗几百 KB 到几 MB 的带宽
  • 如果有后端逻辑:数据库连接数爆满,CPU 被打满,响应时间从毫秒级飙升到超时

二、"略微修改"背后的技术真相

作者说"做了略微修改,应该不会再崩了"。这句话信息量很大。

对于一个测试类 H5 应用,最高效的"略微修改"大概率是以下几种操作之一(或组合):

方案 A:纯静态化 + CDN 分发

测试类应用的核心逻辑其实很简单:展示题目 → 用户选择 → 前端计算结果 → 展示结果页。整个过程完全可以在浏览器端完成,不需要后端服务器参与。

之前:用户 → 源站服务器(动态渲染)
之后:用户 → CDN 边缘节点(静态资源)→ 前端 JS 本地计算结果

把所有页面打包成纯静态文件(HTML + CSS + JS + 图片),扔到 CDN 上。CDN 在全国有几百个边缘节点,用户访问时会自动路由到最近的节点。这样源站压力几乎为零,理论上可以承载千万级并发。

方案 B:更换托管平台

从自建服务器迁移到 Vercel、Cloudflare Pages、Netlify 等现代静态托管平台。这些平台天然具备全球 CDN 分发能力,部署一个静态站点只需要几分钟。

方案 C:Serverless 化

如果测试逻辑中确实有需要后端参与的部分(比如 AI 生成结果文案),可以将后端逻辑迁移到 Serverless 函数(如 AWS Lambda、阿里云函数计算)。Serverless 的核心优势是自动弹性伸缩——流量来了自动扩容,流量走了自动缩容,按实际调用次数计费。

三、如果让你从零设计,架构应该长什么样?

假设你现在要做一个类似 SBTI 的病毒式传播测试应用,并且预期它可能会爆火,推荐的架构如下:

┌─────────────────────────────────────────────────┐
│                    用户浏览器                      │
│  ┌───────────┐  ┌──────────┐  ┌───────────────┐  │
│  │ 答题引擎   │  │ 计分逻辑  │  │ 结果图片生成   │  │
│  │ (纯前端)   │  │ (纯前端)  │  │ (Canvas/SVG)  │  │
│  └───────────┘  └──────────┘  └───────────────┘  │
└──────────────────────┬──────────────────────────┘
                       │ 静态资源请求
                       ▼
              ┌─────────────────┐
              │   CDN 边缘节点    │
              │  (全国 300+ 节点) │
              └────────┬────────┘
                       │ 回源(极少触发)
                       ▼
              ┌─────────────────┐
              │  对象存储 (OSS)   │
              │  HTML/CSS/JS/图片 │
              └─────────────────┘

核心设计原则:

  1. 计算下沉到客户端:题目数据、计分逻辑、结果映射全部内嵌在前端代码中,浏览器本地完成所有计算,服务端零压力
  2. 资源全量 CDN 化:所有静态资源通过 CDN 分发,用户就近访问,首屏加载时间控制在 1-2 秒内
  3. 结果图片客户端生成:使用 Canvas API 或 html2canvas 在用户浏览器中直接生成分享图片,避免服务端图片渲染的性能瓶颈
  4. 零后端依赖:整个应用不需要数据库、不需要后端 API,运维成本趋近于零

四、病毒传播的技术引擎:分享链路优化

SBTI 能刷屏朋友圈,除了内容本身的娱乐性,分享链路的技术设计也至关重要。

微信分享卡片优化

// 微信 JS-SDK 分享配置
wx.updateAppMessageShareData({
  title: '我的SBTI人格是【尤物】,你是什么?',  // 动态标题,包含测试结果
  desc: 'MBTI已经过时了,来测测你的SBTI人格吧',
  link: 'https://example.com/sbti?from=share',   // 带来源追踪参数
  imgUrl: 'https://cdn.example.com/sbti-share.jpg' // 高辨识度的分享缩略图
})

关键技术点:

  • 动态分享标题:将用户的测试结果嵌入分享标题,制造好奇心驱动的点击欲望
  • 结果图片生成:用 Canvas 将测试结果渲染为一张精美的图片,方便用户保存并发到朋友圈
  • 短链接 + UTM 追踪:通过 URL 参数追踪传播路径,了解哪个渠道带来的流量最大

分享图片的客户端生成方案

import html2canvas from 'html2canvas';

async function generateShareImage(resultElement) {
  const canvas = await html2canvas(resultElement, {
    scale: 2,              // 2倍分辨率,保证清晰度
    useCORS: true,         // 允许跨域图片
    backgroundColor: null  // 透明背景
  });
  
  // 转为图片供用户长按保存
  const imgUrl = canvas.toDataURL('image/png');
  return imgUrl;
}

这个方案的好处是:图片在用户手机上生成,不需要服务端渲染,即使同时有 100 万人生成分享图,服务器也毫无压力。

五、AI 在其中扮演的角色

根据公开信息,SBTI 的人格描述内容使用了 AI 生成技术。这带来了一个有趣的架构选择:

方案一:预生成(推荐)

在开发阶段就用 AI 生成好所有人格类型的描述文案,作为静态数据打包到前端代码中。运行时不需要调用 AI 接口,零延迟、零成本。

// 预生成的结果数据,直接内嵌在前端代码中
const SBTI_RESULTS = {
  'ABCD': {
    title: '尤物',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/abcd.png'
  },
  'EFGH': {
    title: '吗喽',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/efgh.png'
  }
  // ... 其他类型
}

方案二:实时生成(不推荐用于病毒传播场景)

每次用户完成测试后实时调用 AI API 生成个性化描述。这种方案在流量暴增时会面临:API 调用成本飙升、响应延迟增大、API 限流等问题。

从 SBTI 的实际表现来看(崩溃后"略微修改"就恢复了),大概率采用的是方案一——AI 只在开发阶段参与内容生产,运行时是纯静态应用。

六、成本算一笔账

假设 SBTI 在爆火期间有 500 万次访问,每次访问加载约 2MB 资源:

方案 预估成本 能否扛住
单台云服务器(2核4G) ¥100/月,但会崩
CDN + 对象存储 流量费约 ¥500-1000
Vercel/Cloudflare Pages 免费版 ¥0(有带宽限制) ⚠️
Vercel Pro + CDN ¥150/月

一个纯静态的测试应用,即使面对百万级流量,CDN 方案的成本也就是一顿火锅钱。而如果用单机硬扛,服务器费用可能不高,但用户体验的损失是无法估量的——多少潜在的传播链路因为"页面打不开"而断裂了。

七、给开发者的 Takeaway

SBTI 事件给我们的启示:

  1. 永远为最好的情况做准备:如果你的产品有社交传播属性,请在架构设计时就考虑流量暴增的场景。CDN + 静态化的成本几乎为零,但收益是巨大的。

  2. 能在前端做的事,别放到后端:测试类应用的计算逻辑完全可以在浏览器端完成。每减少一次服务端请求,就多了一份稳定性。

  3. 分享体验就是增长引擎:结果图片的生成质量、分享卡片的文案设计,直接决定了传播系数。技术上要保证分享链路的流畅性。

  4. AI 是内容生产工具,不是运行时依赖:对于这类应用,AI 最适合在开发阶段批量生成内容,而不是在运行时实时调用。

  5. 小项目也值得好架构:SBTI 的作者最初只是想劝朋友戒酒,没想到会火。但如果一开始就用静态托管方案,根本不会有崩溃这回事。好的架构不一定复杂,但一定要匹配场景。


一个为劝朋友戒酒而生的测试,意外成为了一堂生动的高并发架构课。技术世界的浪漫,大概就是这样吧。


八、最后

文中技术分析基于公开信息推测,不代表 SBTI 实际技术实现。
如果你也在职场摸索成长路线,想了解更多内部跳槽、团队优化、技术实践和职场认知升级的经验,可以关注我的公众号:   [码农职场]
后续我会分享更多干货,帮助你在职场和技术上持续突破。

❌
❌