在上一篇文章中,我们给接口穿上了 DTO 的"防弹衣",用 Prisma 把复杂的 SQL 变成了优雅的"套娃"查询。很多兄弟在评论区催更:"接口安全有了,那用户登录怎么搞?还有,怎么让后端拥有'大脑',接上 AI 大模型?"
别急,今天咱们就继续深挖这个实战项目的核心。我们将把目光投向 AuthModule(认证模块) 和 AIModule(AI 智能模块) 。准备好了吗?咱们继续发车,逐行拆解这套"双保险"认证与"AI 大脑"的底层逻辑!🚀
一、 JWT 双 Token 机制:给登录态装上"双保险"
很多新手在做用户登录时,习惯只发一个 Token,有效期设个 7 天。这就像你把家里的钥匙给了朋友,并且告诉他:"这把钥匙你拿好,7天内随时能进我家。"一旦钥匙丢了,这 7 天里小偷随时能光顾。
为了解决这个安全与体验的矛盾,咱们的项目采用了业界标准的 双 Token 机制。
1. 为什么要用两张"通行证"?
我们可以把 Token 想象成进入公司大楼的证件:
- Access Token(门禁卡) :有效期只有 15分钟。你每次进电梯、刷饭卡都要用。因为它用得频繁,所以有效期极短。就算被坏人偷拍,15分钟后也就失效了,攻击窗口极小。
- Refresh Token(身份证) :有效期长达 7天。它平时藏在你的口袋里,只有当门禁卡过期时,你才需要掏出身份证去前台换一张新的门禁卡。因为它极少在网络中传输,所以非常安全。
2. 逐行拆解 auth.service.ts:Token 的诞生与续命
来看看咱们的 AuthService 是怎么玩转这套机制的:
typescript
// 核心方法:并发生成两张 Token
private async generateTokens(id: string, name: string) {
// 1. 准备载荷(Payload),也就是 Token 里要携带的用户信息
const payload = {
sub: id, // JWT 规范里的 subject,通常用来放用户 ID
name // 顺便把用户名也塞进去,方便前端展示
};
// 2. 使用 Promise.all 并发生成,提升响应速度
const [at, rt] = await Promise.all([
// 生成 Access Token:有效期 15分钟,高频使用,短期有效
this.jwtService.signAsync(payload, {
expiresIn: '15m',
secret: process.env.TOKEN_SECRET
}),
// 生成 Refresh Token:有效期 7天,低频使用,长期有效
this.jwtService.signAsync(payload, {
expiresIn: '7d',
secret: process.env.TOKEN_SECRET
}),
])
return {
access_token: at,
refresh_token: rt
}
}
逐行拆解:
payload:JWT 本质上就是一段经过加密的 JSON 字符串。这里我们把用户 ID (sub) 和用户名 (name) 放进去,以后只要解析 Token,就能知道"是谁在访问"。Promise.all([...]):生成两个 Token 是互不干扰的独立任务。用Promise.all让它们同时去加密,比串行执行快了整整一倍。
接下来是登录和刷新 Token 的逻辑:
typescript
async login(loginDto: LoginDto) {
const { name, password } = loginDto;
// 1. 根据用户名去数据库捞人
const user = await this.prisma.user.findUnique({ where: { name } })
// 2. 如果用户不存在,或者 bcrypt 比对密码失败,直接抛出 401 异常
if(!user || !(await bcrypt.compare(password, user.password))) {
throw new UnauthorizedException('用户名或密码错误')
}
// 3. 密码正确,颁发双 Token,顺便把用户基本信息返回给前端
const tokens = await this.generateTokens(user.id.toString(), user.name);
return { ...tokens, user: { id: user.id.toString(), name: user.name } }
}
async refreshToken(rt: string) {
try {
// 1. 尝试用服务器的密钥去"解码"前端传来的 Refresh Token
const payload = await this.jwtService.verifyAsync(rt, {
secret: process.env.TOKEN_SECRET
});
// 2. 解码成功,说明 Token 没过期也没被篡改,重新签发一对新 Token
return this.generateTokens(payload.sub, payload.name);
} catch(e) {
// 3. 解码失败(比如过期了),让用户重新登录
throw new UnauthorizedException('Refresh Token 已失效,请重新登录')
}
}
逐行拆解:
bcrypt.compare:注册时我们用bcrypt.hash把密码加密成了一串乱码存进数据库。登录时,必须用compare来比对明文和密文,这是保障用户密码安全的核心防线。verifyAsync:这是刷新 Token 的关键。它不仅能检查 Token 是否过期,还能验证 Token 的签名是否被篡改。只要验证通过,我们就无条件信任它,并给用户换发新的"门禁卡"和"身份证"。
3. 守卫的艺术:像安检门一样自动拦截
Token 发出去了,怎么在每次请求时校验它呢?NestJS 的 Passport 和 Guard 完美解决了这个问题。
scala
// jwt.strategy.ts:定义校验规则
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
// 1. 告诉 Passport:去 HTTP 请求头的 Authorization 字段里,提取 Bearer 后面的 Token
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // 2. 绝不放过过期的 Token
secretOrKey: process.env.TOKEN_SECRET || "" // 3. 用这个密钥去解密
});
}
// 4. 解密成功后,Passport 会自动调用这个方法,把 payload 转换成标准的用户对象
async validate(payload) {
return { id: payload.sub, name: payload.name }
}
}
// jwt-auth.guard.ts:真正的"安检门"
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt'){}
逐行拆解:
JwtStrategy:它是背后的"安检员"。它负责从请求头里把 Token 抠出来,用密钥解密。如果解密成功,就把里面的用户信息(id和name)提取出来。JwtAuthGuard:它是挡在接口前的"安检门"。当你在 Controller 里加上@UseGuards(JwtAuthGuard),请求一来,先过安检。安检员(Strategy)验证通过后,会把用户信息自动挂载到req.user上,你的业务代码直接拿来用就行,完全不用操心 Token 校验的繁琐细节。
二、 LangChain 驱动 AI:让后端拥有"大脑"
搞定了安全,咱们来点刺激的。现在的后端不仅要处理 CRUD,还得能跟大模型(LLM)对话。咱们的项目集成了 LangChain 、DeepSeek 和 OpenAI,实现了流式对话、语义搜索、RAG 知识库甚至自动生成 Git 提交日志。
1. 模型初始化:把 AI 变成 Service 的私有属性
在 AIService 的构造函数里,我们把各种 AI 能力都准备好了:
arduino
constructor() {
// 1. 初始化 DeepSeek 大模型,用于日常对话和 Git 日志生成
this.chatModel = new ChatDeepSeek({
configuration: { apiKey: process.env.DEEPSEEK_API_KEY, baseURL: process.env.DEEPSEEK_BASE_URL },
model: 'deepseek-chat',
temperature: 0.7, // 温度值,0.7 代表回答既有一定创造性,又不会太离谱
streaming: true // 【核心】开启流式输出,实现打字机效果
})
// 2. 初始化 OpenAI 的 Embeddings 模型,用于把文字变成向量(做语义搜索和 RAG 的基石)
this.embeddings = new OpenAIEmbeddings({ ... })
// 3. 初始化 DALL-E 3,用于根据用户名画头像
this.imageGenerator = new DallEAPIWrapper({ ... })
// 4. 启动时,异步加载本地的文章向量数据
this.loadPosts();
}
逐行拆解:
streaming: true:这是实现"打字机效果"的关键。如果不打开它,大模型会憋半天,然后一次性把几百个字全吐出来;打开后,它每生成一个字,后端就能立刻收到并推给前端。
2. 流式输出(SSE)实战:让 AI 的回答"流"出来
前端要实现类似 ChatGPT 那种逐字蹦出文字的效果,后端必须使用 SSE(Server-Sent Events) 。来看看 AIController 是怎么做的:
less
@Post('chat')
async chat(@Body() chatDto: ChatDto, @Res() res) {
// 1. 设置 SSE 必需的 HTTP 响应头
res.setHeader('Content-Type', 'text/event-stream'); // 告诉浏览器这是流数据
res.setHeader('Cache-Control', 'no-cache'); // 禁止缓存,保证每次都是最新生成的
res.setHeader('Connection', 'keep-alive'); // 保持长连接不断开
try {
// 2. 调用 Service 层的 chat 方法,传入一个回调函数
await this.aiService.chat(chatDto.messages, (token: string) => {
// 3. 每当大模型吐出一个字(token),就立刻通过 res.write 写给前端
res.write(`0:${JSON.stringify(token)}\n`);
})
res.end(); // 4. 全部生成完毕,结束连接
} catch(err) {
console.error(err);
res.status(500).end(); // 出错也要优雅地关闭连接
}
}
逐行拆解:
@Res() res:在 NestJS 中,默认情况下框架会自动帮你把返回值序列化成 JSON。但 SSE 需要我们要手动控制 HTTP 响应流,所以必须注入原生的Response对象 (@Res())。res.write(...):这就是"打字机"的马达。Service 层每从大模型那里拿到一个文字碎片,Controller 就立刻把它塞进网络管道发给前端。
Service 层的配合也非常简单:
typescript
async chat(messages: Message[], onToken: (token: string) => void) {
// 1. 把前端传来的简单消息数组,转换成 LangChain 能看懂的 HumanMessage/AIMessage 对象
const langChainMessages = convertToLangChainMessages(messages);
// 2. 调用大模型的 stream 方法,它会返回一个异步迭代器
const stream = await this.chatModel.stream(langChainMessages);
// 3. 遍历这个流,每拿到一个字块(chunk),就通过回调函数传给 Controller
for await ( const chunk of stream) {
const content = chunk.content as string;
if (content) {
onToken(content); // 触发 Controller 里的 res.write
}
}
}
3. RAG 与语义搜索:给 AI 装上"本地知识库"
大模型有个毛病,就是会"一本正经地胡说八道"(幻觉)。为了解决这个问题,我们引入了 RAG(检索增强生成) 。它的原理是:在问 AI 之前,先去自己的知识库里找相关资料,然后把资料和问题一起丢给 AI。
语义搜索 (search 方法):
typescript
async search(keyword: string, topK=3) {
// 1. 把用户的搜索关键词,通过 Embeddings 模型变成一个高维向量
const vector = await this.embeddings.embedQuery(keyword);
// 2. 遍历本地加载的文章向量,计算余弦相似度(Cosine Similarity)
const results = this.posts.map(post => ({
...post,
similarity: cosineSimilarity(vector, post.embedding)
}))
// 3. 按相似度从高到低排序,取前 3 个最相关的文章标题
.sort((a, b) => b.similarity - a.similarity)
.slice(0, topK)
.map(item => item.title);
return { code: 0, data: results }
}
RAG 问答 (rag 方法):
typescript
async rag(question: string) {
// 1. 动态构建一个内存向量数据库,把我们的知识库(比如 React、NestJS 的介绍)存进去
const vectorStore = await MemoryVectorStore.fromDocuments([...], this.embeddings)
// 2. 拿着用户的问题去向量库里"相似度搜索",找出最相关的 1 条资料
const docs = await vectorStore.similaritySearch(question, 1);
const context = docs.map(d => d.pageContent).join('\n');
// 3. 【核心】把检索到的资料(Context)拼接到 Prompt 里,一起发给大模型
const prompt = `
你是一个专业的JS工程师,请基于下面资料回答问题。
资料:${context}
问题:${question}
`;
// 4. 大模型基于资料生成回答,极大减少了胡编乱造的概率
const res = await this.chatModel.invoke(prompt);
return res.content;
}
逐行拆解:
cosineSimilarity:这是向量搜索的灵魂。它通过计算两个向量在多维空间中的夹角余弦值,来判断两段文字在"语义"上有多接近。比如搜"前端框架",它能精准匹配到"React",哪怕文字里根本没有"前端框架"这四个字。MemoryVectorStore:这是一个存在于内存中的向量数据库。对于小型项目或 Demo 来说,它无需部署额外的数据库(如 Milvus、PgVector),启动快、零成本,是学习 RAG 的绝佳工具。
4. AI 的奇技淫巧:自动生成 Git Commit Message
最后,给大家展示一个超级实用的 AI 小功能:根据 git diff 自动生成符合规范的提交日志。
typescript
async git(diff: string) {
// 1. 使用 LangChain 的提示词模板,预设好系统指令(System Prompt)
const prompt = ChatPromptTemplate.fromMessages([
["system", `你是资深代码审核专家。请根据用户提供的 git diff 内容,生成一段
符合Conventional Commits 规范的提交日志。
要求:1. 格式为<type>(scope):<subject>。2. 保持简洁。3. 只输出文本。`],
["user", "{diff_content}"]
]);
// 2. 搭建处理链:提示词 -> 大模型 -> 字符串解析器
const chain = prompt.pipe(this.chatModel).pipe(new StringOutputParser());
// 3. 注入具体的 diff 内容,执行链条
const result = await chain.invoke({ diff_content: diff })
return { result }
}
逐行拆解:
ChatPromptTemplate:它允许我们用模板化的方式管理 Prompt。把复杂的指令写在system里,把动态变化的内容(如diff)用占位符{diff_content}代替。.pipe(...):这是 LangChain 最优雅的"链式调用"。数据像水流一样,经过提示词组装,流进大模型,最后经过输出解析器清洗,拿到纯净的结果。
结语
从 JWT 双 Token 的"安检门",到 LangChain 驱动的流式对话与 RAG 知识库,NestJS 展现出了极强的扩展性。它不仅能稳稳地守住后端的安全底线,还能轻松地接上最前沿的 AI 能力,让传统后端真正长出"大脑"。
当你用 res.write 实现流式推送,用 MemoryVectorStore 搭建起私有知识库时,你会发现,AI 应用开发并没有想象中那么神秘。
不过,代码在本地跑得再欢,上线部署才是终极考验。下一期,我们将讲解如何将这套 NestJS + Prisma + AI 的系统容器化,通过 Docker 和 Docker Compose 一键部署上线!想知道怎么把环境配置打包带走吗?点个关注,咱们下期见!👋