普通视图

发现新文章,点击刷新页面。

坏天气别急着收相机:风光摄影的光线、参数与构图思路

作者 Kevin
2026年8月17日 16:18

我以前出去拍风光,也喜欢先挑一个大晴天。

理由很简单。天气稳定,不容易淋雨,到了地方至少看得见山。要是天气预报上挂着一排阴云和雨滴,心里已经先凉了半截,感觉这一趟多半要白跑。

可照片拍得越多,越会发现大晴天其实没那么好伺候。天是蓝的,山是清楚的,光也足,就是画面容易平。景色全看见了,气氛却没留下多少。

反倒是那些一开始不抱希望的天气,经常会送点意外。雨把石头和树叶洗得发亮,雾把乱糟糟的背景藏起来,风让云层和水面动起来。最妙的是雨快停、云还没散的那几分钟,一束光从缝里漏下来,普通山坡一下就有了主角感。

这篇文章不准备把雨、雾、风、雪分成四套标准答案。天气从来不按章节出现,现场也没有一组万能参数。真正有用的,是看懂它此刻给了你什么,再决定下一步怎么拍。

1. 天气 App 上那个图标,信息真的不够

准备出门时,我们最习惯看的就是晴、阴、雨。可对摄影来说,这几个字太粗了。

同样写着多云,有可能是一整块厚云把天封死,也可能是云层不断开合,太阳隔几分钟就从缝里钻出来一次。前一种天气适合拍树林、溪流和局部小景,后一种才有机会等到移动的光斑、耶稣光和火烧云。

所以我现在看天气,会顺手多看几样东西。云量接下来是增加还是减少,雨大概几点停,风从哪个方向吹,湿度和能见度怎样。它们不保证你一定出片,但能帮..... [ 阅读全文 ]


原文链接: https://www.shephe.com/photography/bad-weather-landscape-photography-guide/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

2026 欧洲摄影奖作品赏:好照片不只靠拍得漂亮

作者 Kevin
2026年8月17日 15:31

一张照片能把现场拍清楚,已经不容易;但真正让人反复回看的照片,往往还多做了一件事:它让你突然换了一个角度理解眼前的世界。

2026 欧洲摄影奖(European Photography Awards)这批获奖作品,刚好就是这么一组样本。座头鲸张开嘴的一瞬间,一只海鸥从边缘掠过;越南盐田的工人被压缩成辽阔白色地景里的几个小点;从空中看香港,楼宇不再是“城市风光”,而是一组密集得近乎失真的几何图案。

像素工坊以前写过2026 哈苏大师赛获奖作品索尼世界摄影大赛公开组作品,这次的欧洲摄影奖不一定是国内最耳熟能详的赛事,但它的好处是题材跨度很大。对于平时拍风光、人像、街头或者刚开始做长期专题的朋友,都能从里面找到值得偷师的一点东西。

1. 这个“欧洲摄影奖”,到底是什么来头?

先说个容易让人误解的地方:名字里有“欧洲”,它并不是只收欧洲摄影师的地区赛。

欧洲摄影奖由 International Awards Associate(IAA)主办,面向全球专业摄影师、业余摄影师和学生开放。官方将其定位为当代摄影竞赛,接受商业、纪实、艺术与个人创作等不同方向的投稿。本届收到来自 40 多个国家和地区的 3000 多件作品,采用盲评机制..... [ 阅读全文 ]


原文链接: https://www.shephe.com/post/european-photography-awards-2026-winners/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

Findu — 十年

作者 obaby
2026年8月17日 09:52

前段时间,看到了自己十年前做的这个app。也是自己开发的第一款功能比较完善的app,在22年的时候,自己把他从应用商店下掉了。主要原因是app使用的im基础框架已经不在开放,虽然部分功能还能使用,但是作为一个功能残疾的app挂在外面,也确实没有太大的必要。

在6年间,更新了数个版本,也不断的在完善功能。然而,这个东西可能或许总是没有太多的需求。数年下来不过几千个用户,关键是也没什么收入,既没有广告,也没有内购。

说到这里,也没说这个东西是干嘛的,干脆直接贴商店的简介吧 官网 https://www.findu.co

Findu —— 一款简单的找人定位与好友守护应用

还在因为去找朋友玩却没有详细地址,或有了地址却对这座城市不熟悉、不知道该怎么汇合?何不试试 Findu。只要双方愿意,不必再在其他应用里反复发位置——关系地图上一目了然。

好友相约同一地点,每个人的距离不同,什么时候出门最合适?我们虽不替代专业导航,却能给你最直观的位置与进度,帮你选一个更合适的出发时机。

特别关注:随时了解自己在意的人的动向;一个人外出,也可以让家人知道你在哪里。

我们,只想让生活变得更加简单。
生活本该如此简单。

——————

主要功能

• 关系地图
在地图上同时看到自己与好友的位置,支持刷新、全览、一键导航与地址解析,距离与预计到达时间一目了然。

• 位置共享与足迹
开启共享后按你设定的间隔上报位置;可查看自己与好友的足迹轨迹,回顾行程。

• 好友与分组
搜索添加好友、申请与备注;用分组管理关系,并可按分组控制「谁可以看我的位置」。

• 隐私权限
总开关、分组可见、单好友授权、陌生人是否可见等细粒度控制,未经你允许,他人无法随意查看你的位置。

——————

更多能力

• 可调节定位策略:支持更高频率的位置更新,让好友更及时地看到你的进度;也可选择更省电的间隔。请在「我的 → 位置设置」中按需调整。
• 后台持续共享:在你授权「始终」定位并开启位置共享的前提下,应用进入后台仍可按设置继续上报;你可随时关闭共享。
• 多语言:支持简体中文与 English。

位置共享、好友与地图为免费核心能力;足迹等增值能力详见应用内说明。

iOS链接:https://apps.apple.com/cn/app/findu/id1130623587

安卓apk:https://app.zhongxiaojie.cn/media/packages/2026/08/__UNI__8813F79__20260808160712.apk

而至于开发的初衷,是多年以前宝子的小姨做审计的时候,经常需要出差,全国各地的飞,为了能实时掌握她的动态,所以开发了这么一款应用。

后来,她出差不再那么频繁了,app也就暂停了更新,再到后来阿里的旺信imsdk停止对外服务,这个基于第三方im框架的app算是真的落幕了。

前段时间,自己在群里问要不要重启之前的项目:

J.sky说的很对,说永远都不如做。在这一天,自己重启了这个项目,然而,现阶段面临的问题依然很多:

1.im框架,多数都是收费服务,免费的寥寥无几,几年前曾经调研过野火im,现在貌似因为各种非法滥用也加入了种种限制

2.地图,既然要做定位,总是要有地图服务,高德、百度地图服务企业授权一年5万,个人授权又不知道什么时候就给封禁了。

3.架构,原来的java 和oc的原生代码,已然不合时宜,切换到uni是最快的。当然,这个也是这几个问题里面最容易解决的,毕竟有ai。

4.其他的潜在的一些问题,服务端框架升级,系统架构升级,毕竟靠便宜的ecs在gps数据达到千万级别的时候,拉历史记录就成了一件很痛苦的事情。

5.安卓系统后台保活,这个现在也没想好怎么解决。当然uni有一堆收费插件,这个并不是我想使用的解决方案。

不过,这最后一项项的都还是解决掉了。虽然功能差不多已经全部完成,甚至比之前的功能还有所增加,然而,目前还是把im功能隐藏了。

太多的事情,现在我也不知道是错是对,搞不清,也看不明。

十年,看着好久远,似乎又不过弹指一瞬间。这个图标还是十年前,自己用拙劣的ps技能画出来的,一个心形,加上一个gps图标,这就是全部。这次重构,我没有换掉这个,因为我没有想到更好的创意,当然,现在可以让ai随便生成无数个精美的图标。之前一行一行写的代码,再也不会上场了,成了新项目的参考代码。基于之前的代码重构,ai在页面设计和架构上反而没有花费太多的时间,相对来说推进还是比较顺利的。

而下一个十年,又在哪里?

十年后,我重新findu,也希望有一天能重新findme。

昨天以前首页

索尼创意外观 FL/IN/NT/SH/VV 配置文件,老机型也能用上 A7M5 的直出色彩

作者 Kevin
2026年8月16日 20:50

索尼从 A7M4 开始推出的「创意外观」(Creative Look)功能,让直出色彩有了质的飞跃。特别是 FL 胶片氛围、IN 哑光、VV 鲜艳这几款,在摄影圈里口碑很高,甚至有人拿它们和富士的胶片模拟掰手腕。

但问题是——创意外观是 A7M4、A7R5、A7C2 这些新机型的自带功能,用 A7R3、A7M3、A6000 系列的老用户怎么办?总不能为了一个滤镜效果换相机吧?

其实不用。这套配置文件就是把索尼新机型的创意外观(FL、IN、NT、SH、VV 五款)提取出来,做成了 Lightroom 和 ACR 能用的 XMP 配置文件。不管你用的是什么相机,只要拍 RAW,在后期里一样能套上这些风格。

之前在「索尼 A7R3A 还值得买么?怎么设置颜色最好看?」那篇文章里我分享过这些配置文件的对比效果,这次单独拿出来做一个完整的资源包,方便大家直接下载使用。

1. 索尼创意外观是什么

创意外观(Creative Look)是索尼自 A7M4 开始引入的一套内置色彩预设系统,类似富士的胶片模拟。跟传统 PP 值(Picture Profile)的区别在于,创意外观更注重「直出即用」,不需要后期调色就能获得风格化的画面。

对老机型用户来说,虽然不能在相机里直接选创意外观,但通过这套 XMP 配置文件,在 Lightroom 或 Photoshop Camera Raw 里一样可以应用相同的色彩风格——而且因为是..... [ 阅读全文 ]


原文链接: https://www.shephe.com/preset/sony-creative-look-profiles/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

三个月首保

作者 皇家元林
2026年8月16日 20:48

不知道为啥规定首保是三个月或者5000km,我的爱车不知不觉开了三个月才2000km,首保是免费的,但仅限于第三个月里。所以提前预约好了今天到最近的吉利售后店——安徽宝升售后服务站保养。不过奇怪的是预约的地址却是长江西路上宝利盈4S店。可我跑错到长江西路宝恒4S,找不到售后店,所以便问了4S店门口的店员,一位小姐姐说是刚来的,销售的服务总是很热情的,不管三七二十一,先送上一瓶水。转身帮我找他们领导要了售后店的号码,但是电话打不通,打了好几遍。后来我找到预约订单上的电话打了过去,这才重新导航找到的。唉,这个年代没有导航可怎么办呀?

20260816194055_7_311.jpg

外面停车场挺大的,一时不知道找谁,就先停车。带着行驶证到里面登记,人很多,每个客服前都有人在登记,我也不确定是不是在这里办手续,这时听到一个声音“你是不是来保养的”,顺着声音的方向,一位客服小姐姐,虽然手里还在忙,还是招呼了我,让我先等一下。每一会儿对面的工位上来了一个小姐姐,于是就招呼我过去。没想到现在电子文档时代,这里三联单满天飞。

听外面的工作人员跟另外一个人说现在要等两三个小时,声音挺大的,我转头问客服小姐姐,现在保养要等两三个小时这么久吗?她说差不多,今天车多。“一般什么时候车子不多”,“工作日”。然后让我在休息室等,大概五十多平,一屋子人,有免费的水和零食。我一直注意外面工位上的车,从未看到我的车,大概过了一个小时,微信群里传来消息,发了几个换机油和测电瓶的视频,表示我的车正在保养。但我在外面绕了一圈都没看到我的车。过了几分钟说保养完了,可以到前台取车了。所以回到了前面登记的小姐姐那里,打了售后单签字。结束后,小姐姐带我看了换的机油机滤。我问正常小保养多少钱,她说五百多,然后问我车子不是有年保么?我问什么年保,她问你不是星瑞么?我说不是,是帝豪。哦,帝豪便宜些,四百多。但这比自己保养还是贵了很多。我在纠结,后面在哪保养呢?

看到洗干净的车子,心情好多了!

20260816194056_8_311.jpg
换的机油
20260816194056_9_311.jpg
保养单

在一周前,8月10号,双休,这次双休是一个月前安排好的,因为这天是三姨家的孙女,老表的女儿出嫁。其实那天回来就想记录的,颈椎病犯了,难受,就想躺着。

你还别说三姨三姨夫和老表这长相,表侄女却漂亮的很。不过这两天天公不作美,台风压境,那雨大的像发大水一样。

本来我们打算中午吃一顿饭算了,可我一哥哥想一大早就去,因为那边习俗是这样的——结婚前一天晚上一顿,我哥去了,我没去,我骗他说我喝酒了不能开车。第二天早饭一顿,中午一顿,可以吃三顿。我哥这次回来不开车,叫我一大早去接他一起,再加上四姨,我这次纯当了司机。

八点到那,早饭很简单——稀饭、炸货和咸菜,这个就很家常特色,我很喜欢。然后就是漫长的等待,等男方来接亲。男方头车是一辆奔驰SUV,后面跟了四五辆不同品牌的车。从排场来看,不是那种有钱的公子哥,算是门当户对吧。后来听我哥(因为送亲他也去了)说,男方家那边有一段土路,大概在海拔一千米的大山里,加上大雨天气,可以想象当时的囧况吧,不然电话里跟我吐槽了十几分钟。

11点左右开席,在附近的饭店,先上的荤菜,后上素菜,再后面主食和水果。农村的饭店,口味只能说一般般,吃的太油腻了,基围虾炸的太腥,脆骨太硬,一整只鸡没人动。结束了四姨给了两个盒子,说带点回去给狗吃。我也是第一次打包给狗吃。三姨夫就坐在我旁边,我都没好意思说给狗打包的。那只没动的鸡全夹给我了。这次不得不说狗有福了!

版权声明: 本文采用 BY-NC-SA 协议进行授权,如无注明均为原创,转载请注明转自 皇家元林
本文链接: 三个月首保

尼康云创预设合集:140 款 NP3 滤镜,导入机内直出复古胶片色调

作者 Kevin
2026年8月16日 20:36

尼康 Z 系列用户应该都知道「尼康云创」(Nikon Cloud Creative)这个功能——它可以直接把摄影师调好的色彩配置文件下载到相机里,拍照时直接套用,实现机内直出。但尼康官方云创上的滤镜是一张一张手动下载的,要攒齐一套好用的滤镜还挺费时间。

今天这套资源就是帮你省这个事的。它把截至 2026 年 2 月 25 日尼康云创上所有值得下载的 140 款滤镜预设全部打包好了,直接导入相机或尼康工坊就能用。不用一个一个去点,不用记哪个 id 对应哪个风格。

1. 什么是尼康云创(Nikon Cloud Creative)

尼康云创是尼康官方推出的色彩配方平台,全球的摄影师和创作者可以在这里分享自己调校的 Picture Control 配置文件。这些配方以 .NP3 格式保存,导入到尼康 Z 系列微单(Z8、Z9、Z6 III、Z7 II、Zf 等)或尼康工坊(NX Studio)后,拍照时可以直接作为机内色彩模式选择。

简单说,就是官方版的「富士胶片模拟」,但风格更开放——任何人都可以上传自己的配方,你也能下载别人的方案一键套用。

资源包里的「说明.txt」也提供了尼康工坊的下载链接:Win11/Win12 用户用 [ 阅读全文 ]


原文链接: https://www.shephe.com/preset/nikon-cloud-creative-presets/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

荒井新生风格胶片模拟预设,13 款复古色调滤镜

作者 Kevin
2026年8月16日 19:57

荒井新生的照片有一种独特的质感——明暗反差鲜明,高光泛着淡淡的洋红,暗部偏向冷蓝,整体像一卷扫描出来的胶片,带着恰到好处的灰度。这种风格这几年在婚纱摄影和情侣写真圈子里特别受欢迎,小红书上「荒井新生色调仿色」的教程也是一搜一大把。

今天分享的这套资源,就是直接复刻荒井新生风格的调色配置。包含 13 款 XMP 预设 + 配置文件、9 款视频 LUT,还有 2 个调色 PSD 源文件。不管你是拍照片还是剪视频,Lightroom 用户还是 DaVinci 用户,都能用上。

1. 关于荒井新生

荒井新生(Huangjing Xinsheng)是 2018 年成立于厦门的摄影工作室,在大理也设有独立团队。他们专注婚纱照、情侣写真和商业拍摄,五年间走过 6 个国家 15 个城市,记录了超过 5000 对新人的故事。

他们的风格有这几个标签:

  • 电影视角构图——不以常规的摆拍为主,更注重画面叙事感和电影感
  • 抓拍式拍摄——追求自然真实的瞬间,不是僵硬地对着镜头笑
  • 复古胶片质感——后期调色上高光偏洋红、暗部偏蓝、暗部有灰度,明暗反差明显

这套预设就是围绕他们的调色风格来做的,适合想拍出那种「有故事感的胶片复古风」的用户。

2. 预设包内容一览

[ 阅读全文 ]

原文链接: https://www.shephe.com/preset/huangjing-xinsheng-film-presets/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

泽村洋兵风格 PS / LR 预设,10 款日系清新胶片色调一键套用

作者 Kevin
2026年8月16日 11:08

日系调色最难把握的,是那个「度」。太淡了像没调,太重了就不日系了;想要胶片氛围,又舍不得数码照片那种干净利落的清晰感。日本摄影师泽村洋兵(Yōhei Sawamura)的照片,恰好把这两样东西捏在了一起——柔和得像隔了一层薄雾,细节又扎实得经得起放大。

这位京都摄影师的作品在 Instagram 上有 11 万+ 粉丝关注,人像、街拍、咖啡馆静物,什么题材到他手里都带着一股安静的电影感。今天分享的就是他同款思路的 Lightroom 预设十款,含 All in One 通用款和春夏秋三季系列,XMP 与 lrtemplate 双格式打包。

1. 关于泽村洋兵(Yōhei Sawamura)

泽村洋兵 1985 年出生于京都,如今也定居在京都。他的职业履历相当斜杠:年轻时组过乐队,之后干过美发师、和食料理人、咖啡师和咖啡烘焙师,最后才把摄影变成主业。现在他是企业广告摄影师、SNS 品牌顾问,还负责相机配件品牌 THE Buddy 的运营,出过摄影书,也办过写真集。

他的风格一句话总结:用柔和的低对比光影、素雅干净的色彩,拍出「既朦胧又清晰」的胶片质感——这是他自己对预设的描述,也确实是他照片给人最直接的感受。如果你喜欢日系清新调色,站内这篇仿滨田英明风格预设是同类型,滨田英明偏明亮通透,泽村洋兵更看重氛围感,两者可以对照着玩。

这套..... [ 阅读全文 ]


原文链接: https://www.shephe.com/preset/yohei-sawamura-lightroom-presets/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

16 NestJS 企业级 RBAC 权限控制体系

作者 木灵鱼儿
2026年8月16日 11:04

前言

权限控制是企业级应用中最容易"看起来做了,实则一捅就破"的模块。常见的伪 RBAC 实现往往是在 Controller 里直接判断 user.role === 'admin',把角色硬编码散落在各处,后期维护极其痛苦。

本文将从架构设计出发,基于 NestJS + Prisma + Redis + CASL 构建一套真正可扩展的 RBAC 体系,覆盖以下核心问题:

  • 纯角色判断为何不够?如何建模"资源:动作"级别的原子权限?
  • NestJS 的守卫管道中,认证与授权如何职能分离?
  • 每次请求都查数据库权限太慢,如何用 Redis 将查询压缩到毫秒级?
  • 用户拥有 article:delete 权限,如何限制只能删自己的文章?

阅读本文需要具备 NestJS 模块化开发经验,熟悉 Prisma 基本用法和 JWT 认证流程。建议先阅读本系列的 《10 NestJS JWT 身份验证完全指南》 和 《12 NestJS 集成 Prisma ORM 完全指南》。


[hide]

一、架构设计与理论基础

1.1 RBAC 模型的演进

基础 RBAC(User ➔ Role)

最简单的实现:给用户打上 admineditorviewer 标签,守卫里判断角色字符串。问题在于角色是粗粒度的——同样是 editor,有人可以发布文章,有人只能保存草稿。随着业务增长,角色数量膨胀(senior_editorjunior_editor……),维护成本失控。

标准 RBAC(User ➔ Role ➔ Permission)

在角色和用户之间引入"权限"这一中间层。权限采用 [资源]:[动作] 的命名规范:

article:create    article:read    article:update    article:delete    article:publish
user:create       user:read       user:update       user:delete
system:config:read  system:config:update

角色是权限的集合,用户通过角色继承权限。这样可以精确控制每个角色的能力边界,新增权限点只需修改角色配置,不用改代码。

RBAC + ABAC 融合(行级数据权限)

标准 RBAC 仍然无法解决"只能操作自己创建的数据"这类行级权限问题。此时需要引入属性访问控制(ABAC)的思想——判断资源的属性(authorId)是否与当前用户匹配。本文第六节使用 CASL 处理这个场景。

1.2 NestJS 授权生命周期

请求在 NestJS 管道中的执行顺序:

HTTP 请求
   ↓
Middleware(如日志、请求 ID)
   ↓
Guards(身份验证 → 权限验证)    ← 授权在这里发生
   ↓
Interceptors(前置)
   ↓
Pipes(参数转换与校验)
   ↓
Route Handler
   ↓
Interceptors(后置)
   ↓
Exception Filters(异常时触发)

守卫(Guard)是授权逻辑的正确位置。本文将守卫拆为两层:

守卫职责
JwtAuthGuard认证:验证 token 有效性,将用户信息挂载到 req.user
PermissionsGuard授权:检查当前用户是否拥有访问该端点所需的权限

两者注册顺序必须保证认证先于授权,详见第四节。

1.3 技术栈

用途
@nestjs/jwtJWT 签发与验证
prismaORM,权限数据持久化
ioredisRedis 客户端,权限缓存
@casl/abilityABAC 策略引擎,处理行级权限

二、数据库建模

2.1 Prisma Schema 设计

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  /// 用户唯一标识,自增主键
  id        Int       @id @default(autoincrement())
  /// 登录邮箱,全局唯一
  email     String    @unique
  /// Argon2id 哈希后的密码,禁止明文存储
  password  String
  /// 记录创建时间,由数据库自动填充
  createdAt DateTime  @default(now())
  /// 记录最后更新时间,由 Prisma 自动维护
  updatedAt DateTime  @updatedAt
  /// 软删除时间戳;非 null 表示该用户已被删除,所有查询必须附加 deletedAt: null 过滤条件
  deletedAt DateTime?

  @@map("users")
}

model Role {
  /// 角色唯一标识,自增主键
  id          Int      @id @default(autoincrement())
  /// 角色英文标识符,如 super_admin、editor、viewer,全局唯一,用于代码逻辑判断
  name        String   @unique
  /// 角色描述,供管理界面展示,可为空
  description String?
  /// 记录创建时间,由数据库自动填充
  createdAt   DateTime @default(now())

  @@map("roles")
}

model Permission {
  /// 权限唯一标识,自增主键
  id          Int      @id @default(autoincrement())
  /// 权限标识符,格式为 [模块]:[资源]:[动作],如 cms:article:publish、system:user:delete,全局唯一
  action      String   @unique
  /// 权限描述,供管理界面展示,可为空
  description String?
  /// 记录创建时间,由数据库自动填充
  createdAt   DateTime @default(now())

  @@map("permissions")
}

model UserRole {
  /// 用户 ID,逻辑上关联 users.id,不设数据库外键约束,由应用层保证一致性
  userId    Int
  /// 角色 ID,逻辑上关联 roles.id,不设数据库外键约束,由应用层保证一致性
  roleId    Int
  /// 角色分配时间,由数据库自动填充
  createdAt DateTime @default(now())

  /// 复合主键,天然防止同一用户重复分配同一角色
  @@id([userId, roleId])
  @@index([userId])
  @@index([roleId])
  @@map("user_roles")
}

model RolePermission {
  /// 角色 ID,逻辑上关联 roles.id,不设数据库外键约束,由应用层保证一致性
  roleId       Int
  /// 权限 ID,逻辑上关联 permissions.id,不设数据库外键约束,由应用层保证一致性
  permissionId Int
  /// 权限分配时间,由数据库自动填充
  createdAt    DateTime @default(now())

  /// 复合主键,天然防止同一角色重复分配同一权限
  @@id([roleId, permissionId])
  @@index([roleId])
  @@map("role_permissions")
}

几个设计要点:

  1. 软删除User 表的 deletedAt 字段在查询时需要配合 where: { deletedAt: null } 过滤,防止已删除用户仍能登录。
  2. 复合主键@@id([userId, roleId]) 比单独的自增 id 加唯一索引更简洁,同时避免重复分配。
  3. 无外键约束:遵循阿里规范,UserRoleRolePermission 中的关联 ID 均为普通整型字段,不设数据库外键。删除用户或角色时,需在应用层手动清理关联记录(见 Service 层事务处理)。

2.2 权限命名规范

推荐使用三段式命名,确保全局唯一且语义清晰:

[模块]:[资源]:[动作]

示例:

权限字符串说明
cms:article:createCMS 模块,创建文章
cms:article:publishCMS 模块,发布文章
system:user:delete系统模块,删除用户
system:role:assign系统模块,分配角色
*:*:*超级管理员通配符

在 TypeScript 中强类型化:

// src/common/types/permission.type.ts

// 从字符串字面量构造权限类型,IDE 可以提供自动补全
export const PERMISSIONS = {
    CMS_ARTICLE_CREATE: "cms:article:create",
    CMS_ARTICLE_READ: "cms:article:read",
    CMS_ARTICLE_UPDATE: "cms:article:update",
    CMS_ARTICLE_DELETE: "cms:article:delete",
    CMS_ARTICLE_PUBLISH: "cms:article:publish",
    SYSTEM_USER_CREATE: "system:user:create",
    SYSTEM_USER_READ: "system:user:read",
    SYSTEM_USER_UPDATE: "system:user:update",
    SYSTEM_USER_DELETE: "system:user:delete",
    SYSTEM_ROLE_ASSIGN: "system:role:assign",
    WILDCARD: "*:*:*",
} as const;

export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];

2.3 Seed 脚本

执行迁移后,通过 seed 脚本初始化系统预置数据:

// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma";
import { hash } from "@node-rs/argon2";

const prisma = new PrismaClient();

async function main() {
    // 1. 创建原子权限
    const permissions = await Promise.all([
        prisma.permission.upsert({
            where: { action: "cms:article:create" },
            update: {},
            create: { action: "cms:article:create", description: "创建文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:read" },
            update: {},
            create: { action: "cms:article:read", description: "查看文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:update" },
            update: {},
            create: { action: "cms:article:update", description: "编辑文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:delete" },
            update: {},
            create: { action: "cms:article:delete", description: "删除文章" },
        }),
        prisma.permission.upsert({
            where: { action: "cms:article:publish" },
            update: {},
            create: { action: "cms:article:publish", description: "发布文章" },
        }),
        prisma.permission.upsert({
            where: { action: "system:user:delete" },
            update: {},
            create: { action: "system:user:delete", description: "删除用户" },
        }),
        prisma.permission.upsert({
            where: { action: "system:role:assign" },
            update: {},
            create: { action: "system:role:assign", description: "分配角色" },
        }),
        prisma.permission.upsert({
            where: { action: "*:*:*" },
            update: {},
            create: { action: "*:*:*", description: "超级管理员" },
        }),
    ]);

    const permMap = Object.fromEntries(permissions.map((p) => [p.action, p]));

    // 2. 创建角色并分配权限
    const superAdminRole = await prisma.role.upsert({
        where: { name: "super_admin" },
        update: {},
        create: { name: "super_admin", description: "超级管理员,拥有所有权限" },
    });

    const editorRole = await prisma.role.upsert({
        where: { name: "editor" },
        update: {},
        create: { name: "editor", description: "内容编辑,可管理文章" },
    });

    // 为 super_admin 分配通配符权限
    await prisma.rolePermission.upsert({
        where: {
            roleId_permissionId: { roleId: superAdminRole.id, permissionId: permMap["*:*:*"].id },
        },
        update: {},
        create: { roleId: superAdminRole.id, permissionId: permMap["*:*:*"].id },
    });

    // 为 editor 分配文章相关权限(不含删除)
    for (const action of [
        "cms:article:create",
        "cms:article:read",
        "cms:article:update",
        "cms:article:publish",
    ]) {
        await prisma.rolePermission.upsert({
            where: { roleId_permissionId: { roleId: editorRole.id, permissionId: permMap[action].id } },
            update: {},
            create: { roleId: editorRole.id, permissionId: permMap[action].id },
        });
    }

    // 3. 创建超级管理员账号
    const hashedPassword = await hash("Admin@123456");
    const superAdmin = await prisma.user.upsert({
        where: { email: "admin@example.com" },
        update: {},
        create: { email: "admin@example.com", password: hashedPassword },
    });

    await prisma.userRole.upsert({
        where: { userId_roleId: { userId: superAdmin.id, roleId: superAdminRole.id } },
        update: {},
        create: { userId: superAdmin.id, roleId: superAdminRole.id },
    });

    console.log("Seed 完成");
}

main()
    .catch(console.error)
    .finally(() => prisma.$disconnect());

Prisma v7 通过 prisma.config.ts 统一管理配置,seed 命令不再需要写在 package.json 中。在项目根目录创建 prisma.config.ts

// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
    schema: "prisma/schema.prisma",
    migrations: {
        path: "prisma/migrations",
        seed: "tsx prisma/seed.ts",
    },
    datasource: {
        url: env("DATABASE_URL"),
    },
});

执行:

pnpm prisma db seed

三、认证前置与上下文传递

3.1 JWT Payload 设计

权限数据不应存入 JWT。原因有两点:

  1. Token 体积:一个用户可能拥有数十条权限,全部写入 payload 会使 token 体积膨胀数倍,每次请求都在 Header 中传输。
  2. 权限实时性:JWT 签发后在有效期内不可更改。若管理员在 token 有效期内撤销了某用户的角色,token 中的权限信息仍然有效,存在安全漏洞。

正确做法是 payload 只携带最小必要字段,每次请求到守卫时动态查询(或命中 Redis 缓存):

// src/auth/types/jwt-payload.type.ts

export interface JwtPayload {
    sub: number; // 用户 ID(标准字段)
    email: string; // 少量辅助信息,方便日志
    iat?: number; // 签发时间(自动注入)
    exp?: number; // 过期时间(自动注入)
}

3.2 自定义装饰器封装

@CurrentUser() 参数装饰器:从 req.user 安全提取当前用户,避免在每个 Handler 里重复写 @Req() req

// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { Request } from "express";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

export const CurrentUser = createParamDecorator(
    (_data: unknown, ctx: ExecutionContext): JwtPayload => {
        const request = ctx.switchToHttp().getRequest<Request>();
        return request.user as JwtPayload;
    },
);

@Public() 元数据装饰器:标记无需鉴权的路由(登录、注册、公开接口):

// src/common/decorators/public.decorator.ts
import { SetMetadata } from "@nestjs/common";

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

JwtAuthGuard 整合 @Public()

// src/common/guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

@Injectable()
export class JwtAuthGuard implements CanActivate {
    constructor(
        private readonly jwtService: JwtService,
        private readonly reflector: Reflector,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        // 检查路由是否标记为公开
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);
        if (isPublic) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const token = this.extractTokenFromHeader(request);

        if (!token) throw new UnauthorizedException("缺少认证 Token");

        try {
            const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
            // 挂载到 request,供后续守卫和装饰器使用
            request["user"] = payload;
        } catch {
            throw new UnauthorizedException("Token 无效或已过期");
        }

        return true;
    }

    private extractTokenFromHeader(request: Request): string | null {
        const [type, token] = request.headers.authorization?.split(" ") ?? [];
        return type === "Bearer" ? token : null;
    }
}

四、核心实现:声明式权限守卫

4.1 权限元数据装饰器

// src/common/decorators/require-permissions.decorator.ts
import { Reflector } from "@nestjs/core";
import { Permission } from "../types/permission.type";

export interface PermissionOptions {
    permissions: Permission[];
    // ALL = 需要满足所有权限;ANY = 满足其中一个即可(默认)
    mode?: "ALL" | "ANY";
}

export const RequirePermissions = Reflector.createDecorator<PermissionOptions>();

使用示例:

// 需要同时拥有 create 和 publish 权限
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_CREATE, PERMISSIONS.CMS_ARTICLE_PUBLISH], mode: 'ALL' })
@Post('publish')
publishArticle() {}

// 拥有 read 或 wildcard 其中一个即可
@RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_READ] })
@Get()
listArticles() {}

4.2 权限查询服务

将数据库查询封装到独立服务,便于 Guard 调用和测试:

// src/auth/permission.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";

@Injectable()
export class PermissionService {
    constructor(private readonly prisma: PrismaService) {}

    // 分三步独立查询,避免嵌套联表
    async getUserPermissions(userId: number): Promise<Set<string>> {
        // 第一步:查出用户拥有的所有角色 ID
        const userRoles = await this.prisma.userRole.findMany({
            where: { userId },
            select: { roleId: true },
        });
        const roleIds = userRoles.map((ur) => ur.roleId);
        if (roleIds.length === 0) return new Set();

        // 第二步:查出这些角色关联的所有权限 ID
        const rolePermissions = await this.prisma.rolePermission.findMany({
            where: { roleId: { in: roleIds } },
            select: { permissionId: true },
        });
        const permissionIds = rolePermissions.map((rp) => rp.permissionId);
        if (permissionIds.length === 0) return new Set();

        // 第三步:查出权限的 action 字符串
        const permissions = await this.prisma.permission.findMany({
            where: { id: { in: permissionIds } },
            select: { action: true },
        });

        return new Set(permissions.map((p) => p.action));
    }
}

4.3 核心 PermissionsGuard 实现

// src/common/guards/permissions.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { PermissionOptions, RequirePermissions } from "../decorators/require-permissions.decorator";
import { PermissionService } from "../../auth/permission.service";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
import { PERMISSIONS } from "../types/permission.type";

@Injectable()
export class PermissionsGuard implements CanActivate {
    constructor(
        private readonly reflector: Reflector,
        private readonly permissionService: PermissionService,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        // 合并 Class 级别和 Handler 级别的元数据,Handler 优先
        const options = this.reflector.getAllAndOverride<PermissionOptions>(RequirePermissions, [
            context.getHandler(),
            context.getClass(),
        ]);

        // 未声明权限要求,直接放行
        if (!options) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const user = request["user"] as JwtPayload;

        // JwtAuthGuard 应在此之前执行,正常不会走到这里
        if (!user) throw new ForbiddenException("无法识别当前用户");

        const userPermissions = await this.permissionService.getUserPermissions(user.sub);

        // 超级管理员通配符快速放行
        if (userPermissions.has(PERMISSIONS.WILDCARD)) return true;

        const { permissions, mode = "ANY" } = options;

        const hasPermission =
            mode === "ALL"
                ? permissions.every((p) => userPermissions.has(p))
                : permissions.some((p) => userPermissions.has(p));

        if (!hasPermission) {
            throw new ForbiddenException("权限不足");
        }

        return true;
    }
}

4.4 全局注册

将两个守卫通过 APP_GUARD 注册为全局守卫,注意顺序:JwtAuthGuard 必须在 PermissionsGuard 之前,因为 PermissionsGuard 依赖 req.user

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { PermissionsGuard } from "./common/guards/permissions.guard";

@Module({
    providers: [
        {
            provide: APP_GUARD,
            useClass: JwtAuthGuard, // 第一个执行
        },
        {
            provide: APP_GUARD,
            useClass: PermissionsGuard, // 第二个执行
        },
    ],
})
export class AppModule {}

确保 PermissionService 所在模块已在 AppModule 中导入,或将其放在 AuthModule 并 export:

// src/auth/auth.module.ts(片段)
@Module({
    providers: [AuthService, PermissionService],
    exports: [AuthService, PermissionService, JwtModule],
})
export class AuthModule {}

Controller 中的实际用法:

// src/cms/article.controller.ts
import { Controller, Get, Post, Delete, Param, Body } from "@nestjs/common";
import { RequirePermissions } from "../common/decorators/require-permissions.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { PERMISSIONS } from "../common/types/permission.type";
import { JwtPayload } from "../auth/types/jwt-payload.type";

@Controller("articles")
export class ArticleController {
    @Public()
    @Get()
    listPublished() {
        // 公开接口,无需登录
    }

    @RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_CREATE] })
    @Post()
    create(@Body() dto: CreateArticleDto, @CurrentUser() user: JwtPayload) {}

    @RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_DELETE] })
    @Delete(":id")
    remove(@Param("id") id: string, @CurrentUser() user: JwtPayload) {}
}

五、性能优化:Redis 权限缓存

5.1 问题分析

每次 HTTP 请求到达 PermissionsGuard 时,getUserPermissions 都会触发三次独立数据库查询。在并发较高的场景下,这既是数据库压力,也是响应时延的主要来源。

解决方案:用户登录后(或首次权限查询时)将权限集合缓存到 Redis,后续请求直接读 Redis,权限变更时主动清除缓存。

5.2 Redis 客户端模块

// src/redis/redis.module.ts
import { Module, Global } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Redis from "ioredis";

export const REDIS_CLIENT = "REDIS_CLIENT";

@Global()
@Module({
    providers: [
        {
            provide: REDIS_CLIENT,
            inject: [ConfigService],
            useFactory: (config: ConfigService) => {
                return new Redis({
                    host: config.get("REDIS_HOST", "localhost"),
                    port: config.get<number>("REDIS_PORT", 6379),
                    password: config.get("REDIS_PASSWORD"),
                    db: config.get<number>("REDIS_DB", 0),
                });
            },
        },
    ],
    exports: [REDIS_CLIENT],
})
export class RedisModule {}

5.3 缓存键规范与 TTL 策略

// src/auth/permission-cache.service.ts
import { Inject, Injectable } from "@nestjs/common";
import { Redis } from "ioredis";
import { REDIS_CLIENT } from "../redis/redis.module";

const PERM_CACHE_KEY = (userId: number) => `user:perms:${userId}`;
// 权限缓存 TTL 设为 15 分钟,与 access token 有效期对齐
const PERM_CACHE_TTL = 15 * 60;

@Injectable()
export class PermissionCacheService {
    constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}

    async getPermissions(userId: number): Promise<Set<string> | null> {
        const key = PERM_CACHE_KEY(userId);
        const members = await this.redis.smembers(key);
        if (members.length === 0) return null;
        return new Set(members);
    }

    async setPermissions(userId: number, permissions: Set<string>): Promise<void> {
        const key = PERM_CACHE_KEY(userId);
        const pipeline = this.redis.pipeline();
        pipeline.del(key);
        if (permissions.size > 0) {
            pipeline.sadd(key, ...permissions);
            pipeline.expire(key, PERM_CACHE_TTL);
        }
        await pipeline.exec();
    }

    /** 管理员修改角色权限时,批量清除受影响用户的缓存 */
    async invalidateByUserIds(userIds: number[]): Promise<void> {
        if (userIds.length === 0) return;
        const keys = userIds.map(PERM_CACHE_KEY);
        await this.redis.del(...keys);
    }

    async invalidate(userId: number): Promise<void> {
        await this.redis.del(PERM_CACHE_KEY(userId));
    }
}

5.4 改造 PermissionService

// src/auth/permission.service.ts
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../database/prisma.service";
import { PermissionCacheService } from "./permission-cache.service";

@Injectable()
export class PermissionService {
    constructor(
        private readonly prisma: PrismaService,
        private readonly cache: PermissionCacheService,
    ) {}

    async getUserPermissions(userId: number): Promise<Set<string>> {
        // 1. 优先命中缓存
        const cached = await this.cache.getPermissions(userId);
        if (cached) return cached;

        // 2. 缓存未命中,分三步独立查询
        const userRoles = await this.prisma.userRole.findMany({
            where: { userId },
            select: { roleId: true },
        });
        const roleIds = userRoles.map((ur) => ur.roleId);

        const permissions = new Set<string>();
        if (roleIds.length > 0) {
            const rolePermissions = await this.prisma.rolePermission.findMany({
                where: { roleId: { in: roleIds } },
                select: { permissionId: true },
            });
            const permissionIds = rolePermissions.map((rp) => rp.permissionId);

            if (permissionIds.length > 0) {
                const permRecords = await this.prisma.permission.findMany({
                    where: { id: { in: permissionIds } },
                    select: { action: true },
                });
                permRecords.forEach((p) => permissions.add(p.action));
            }
        }

        // 3. 回写缓存
        await this.cache.setPermissions(userId, permissions);

        return permissions;
    }
}

5.5 权限变更时的缓存失效

当管理员修改角色的权限时,需要找出所有拥有该角色的用户并清除其缓存:

// src/system/role.service.ts(片段)
async updateRolePermissions(roleId: number, permissionIds: number[]): Promise<void> {
  await this.prisma.$transaction(async (tx) => {
    // 删除旧权限关联
    await tx.rolePermission.deleteMany({ where: { roleId } });
    // 写入新权限关联
    await tx.rolePermission.createMany({
      data: permissionIds.map(permissionId => ({ roleId, permissionId })),
    });
  });

  // 查找所有拥有该角色的用户 ID
  const affectedUserRoles = await this.prisma.userRole.findMany({
    where: { roleId },
    select: { userId: true },
  });
  const userIds = affectedUserRoles.map(ur => ur.userId);

  // 批量清除缓存,下次请求时重新从数据库加载
  await this.permissionCache.invalidateByUserIds(userIds);
}

六、进阶:CASL 策略权限控制

6.1 纯 RBAC 的局限

假设 editor 角色拥有 cms:article:delete 权限,但业务规则是"编辑只能删除自己创建的文章"。纯 RBAC 无法表达这种"属于谁"的条件,需要引入 CASL。

安装:

pnpm add @casl/ability

6.2 定义 Ability 类型

// src/casl/casl.types.ts
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability";

// 定义系统中所有可操作的动作
export type Action = "create" | "read" | "update" | "delete" | "publish" | "manage";

// 定义所有受保护的资源类型
export type Subject = "Article" | "User" | "Role" | "all";

export type AppAbility = MongoAbility<[Action, Subject]>;
export type AbilityBuilderType = AbilityBuilder<AppAbility>;

6.3 CaslAbilityFactory

// src/casl/casl-ability.factory.ts
import { Injectable } from "@nestjs/common";
import { AbilityBuilder, createMongoAbility } from "@casl/ability";
import { AppAbility, Action, Subject } from "./casl.types";
import { JwtPayload } from "../auth/types/jwt-payload.type";
import { PermissionService } from "../auth/permission.service";

// 文章实体的简化类型,包含 authorId 供行级检查
export interface ArticleSubject {
    __type: "Article";
    id: number;
    authorId: number;
    [key: string]: unknown;
}

@Injectable()
export class CaslAbilityFactory {
    constructor(private readonly permissionService: PermissionService) {}

    async createForUser(user: JwtPayload): Promise<AppAbility> {
        const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
        const permissions = await this.permissionService.getUserPermissions(user.sub);

        // 超级管理员拥有所有能力
        if (permissions.has("*:*:*")) {
            can("manage", "all");
            return build();
        }

        // 根据权限集合构建 Ability 规则
        if (permissions.has("cms:article:read")) can("read", "Article");
        if (permissions.has("cms:article:create")) can("create", "Article");
        if (permissions.has("cms:article:publish")) can("publish", "Article");

        if (permissions.has("cms:article:update")) {
            // 普通用户只能编辑自己的文章
            can("update", "Article", { authorId: user.sub });
        }

        if (permissions.has("cms:article:delete")) {
            // 普通用户只能删除自己的文章
            can("delete", "Article", { authorId: user.sub });
        }

        return build();
    }
}

6.4 策略守卫与装饰器

策略接口

// src/casl/casl.types.ts(追加)
export interface IPolicyHandler {
    handle(ability: AppAbility): boolean;
}

export type PolicyHandlerCallback = (ability: AppAbility) => boolean;
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

@CheckPolicies() 装饰器

// src/common/decorators/check-policies.decorator.ts
import { Reflector } from "@nestjs/core";
import { PolicyHandler } from "../../casl/casl.types";

export const CheckPolicies = Reflector.createDecorator<PolicyHandler[]>();

PoliciesGuard

// src/common/guards/policies.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { CheckPolicies } from "../decorators/check-policies.decorator";
import { CaslAbilityFactory } from "../../casl/casl-ability.factory";
import { AppAbility, PolicyHandler } from "../../casl/casl.types";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

@Injectable()
export class PoliciesGuard implements CanActivate {
    constructor(
        private readonly reflector: Reflector,
        private readonly caslAbilityFactory: CaslAbilityFactory,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const policyHandlers = this.reflector.getAllAndOverride<PolicyHandler[]>(CheckPolicies, [
            context.getHandler(),
            context.getClass(),
        ]);

        if (!policyHandlers) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const user = request["user"] as JwtPayload;

        const ability = await this.caslAbilityFactory.createForUser(user);

        const allowed = policyHandlers.every((handler) =>
            typeof handler === "function" ? handler(ability) : handler.handle(ability),
        );

        if (!allowed) throw new ForbiddenException("权限不足");

        return true;
    }
}

Controller 中的实际应用

// src/cms/article.controller.ts(行级权限场景)
import { CheckPolicies } from "../common/decorators/check-policies.decorator";
import { AppAbility } from "../casl/casl.types";

@Controller("articles")
export class ArticleController {
    constructor(private readonly articleService: ArticleService) {}

    // 删除文章:守卫先检查 RBAC 层(有无 cms:article:delete 权限),
    // 再通过 CASL 检查行级(是否为作者)
    @RequirePermissions({ permissions: [PERMISSIONS.CMS_ARTICLE_DELETE] })
    @CheckPolicies([(ability: AppAbility) => ability.can("delete", "Article")])
    @Delete(":id")
    async remove(@Param("id") id: string, @CurrentUser() user: JwtPayload) {
        // 此处还需在 Service 层查出文章,结合 subject 做最终检查
        return this.articleService.removeIfAllowed(+id, user.sub);
    }
}

Service 层的最终校验

// src/cms/article.service.ts(片段)
async removeIfAllowed(articleId: number, currentUserId: number): Promise<void> {
  const article = await this.prisma.article.findUniqueOrThrow({
    where: { id: articleId },
  });

  // 构造带 __type 标记的 subject 供 CASL 匹配
  const subject = { __type: 'Article' as const, ...article };
  const ability = await this.caslAbilityFactory.createForUser({ sub: currentUserId } as any);

  if (ability.cannot('delete', subject)) {
    throw new ForbiddenException('只能删除自己创建的文章');
  }

  await this.prisma.article.delete({ where: { id: articleId } });
}

七、异常处理与安全审计

7.1 精细化 403 响应

到这里为止,权限判断已经可以工作,但还缺少生产环境必须关注的两件事:

  1. 对外响应必须稳定:前端、网关、客户端 SDK 不能因为不同守卫抛出的异常不同,就收到不同结构的错误对象。
  2. 对内日志必须足够具体:安全团队和后端排查问题时,需要知道是谁、在什么时候、访问了哪个接口、为什么被拒绝。

这两者不能混在一起。对外响应越克制越好,避免暴露内部权限点;对内日志越完整越好,便于审计和追踪。

权限不足时,PermissionsGuardPoliciesGuard 或 Service 层最终校验都会抛出 ForbiddenException。它不应该在守卫内部手动拼响应,而是交给全局异常过滤器统一处理。

如果项目已经按本系列 《14 NestJS 生产级错误过滤方案》 和 《15 NestJS 统一响应体设计(信封模式)》 实现了过滤器链路,那么最终对外响应应保持统一的信封格式:

{
    "code": 40301,
    "message": "权限不足",
    "data": null,
    "requestId": "abc-123"
}

其中 code 可以使用通用的 ErrorCode.FORBIDDEN。如果想区分“登录了但没有权限”和“具备权限点但不满足行级条件”,也可以在第 15 篇定义的错误码枚举中增加更细的权限错误码:

// src/common/exceptions/error-codes.ts
export enum ErrorCode {
    // ...
    FORBIDDEN = 40301,
    PERMISSION_DENIED = 40302,
    RESOURCE_OWNERSHIP_DENIED = 40303,
}

然后在业务代码中抛出带业务码的异常:

// src/common/exceptions/business.exception.ts
throw new BusinessException("权限不足", HttpStatus.FORBIDDEN, ErrorCode.PERMISSION_DENIED);

对于普通的 NestJS ForbiddenException("权限不足"),第 15 篇里的 AllExceptionsFilter 会根据 HTTP 状态码生成默认业务码,最终仍然返回统一的 ApiResponseDto.failed() 结构:

// src/common/filters/all-exceptions.filter.ts(关键逻辑)
const responseBody = ApiResponseDto.failed(code ?? this.getDefaultCode(statusCode), message);

if (meta.requestId) responseBody.requestId = meta.requestId;
response.status(statusCode).json(responseBody);

注意不要在响应中暴露“需要 cms:article:delete 权限”“缺少 system:user:delete 权限”之类的具体提示。这类信息应该进入服务端日志,而不是返回给客户端,否则会给攻击者提供枚举权限点和接口能力边界的线索。

推荐策略:

场景对外 message对内日志
未登录访问受保护接口请先登录记录 IP、URL、User-Agent、requestId
登录但缺少接口权限权限不足记录 userId、URL、requiredPermissions、userPermissions
不满足行级权限权限不足记录 userId、resourceType、resourceId、ownerId、action
权限配置异常权限不足记录 routeKey、metadata、缺失的权限配置,并触发告警

7.2 安全审计日志

权限系统的日志不能只依赖普通应用日志。普通日志关注“接口是否报错”,而安全审计关注“是否存在越权尝试、权限探测、异常访问模式”。

审计日志建议覆盖三类事件:

事件触发位置示例
认证失败JwtAuthGuardToken 缺失、过期、伪造
接口权限不足PermissionsGuard没有 cms:article:delete 权限
行级权限不足Service 层或 CASL 最终校验试图删除不属于自己的文章

最简单的做法是在全局拦截器中捕获 ForbiddenException,记录越权访问尝试:

// src/common/interceptors/audit.interceptor.ts
import {
    CallHandler,
    ExecutionContext,
    Injectable,
    NestInterceptor,
    ForbiddenException,
    Logger,
} from "@nestjs/common";
import { Observable, catchError, throwError } from "rxjs";
import { Request } from "express";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

@Injectable()
export class AuditInterceptor implements NestInterceptor {
    private readonly logger = new Logger("AuditLog");

    intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
        const request = context.switchToHttp().getRequest<Request>();
        const user = request["user"] as JwtPayload | undefined;
        const { method, url, ip } = request;
        const requestId = request.headers["x-request-id"] as string | undefined;
        const userAgent = request.headers["user-agent"];

        return next.handle().pipe(
            catchError((err) => {
                if (err instanceof ForbiddenException) {
                    this.logger.warn({
                        event: "UNAUTHORIZED_ACCESS_ATTEMPT",
                        userId: user?.sub ?? "anonymous",
                        method,
                        url,
                        ip,
                        userAgent,
                        requestId,
                        timestamp: new Date().toISOString(),
                    });
                }
                return throwError(() => err);
            }),
        );
    }
}

全局注册:

// src/app.module.ts
import { APP_INTERCEPTOR } from "@nestjs/core";
import { AuditInterceptor } from "./common/interceptors/audit.interceptor";

@Module({
    providers: [{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor }],
})
export class AppModule {}

审计日志样例输出:

{
    "event": "UNAUTHORIZED_ACCESS_ATTEMPT",
    "userId": 42,
    "method": "DELETE",
    "url": "/articles/99",
    "ip": "::1",
    "userAgent": "Mozilla/5.0 ...",
    "requestId": "abc-123",
    "timestamp": "2026-08-16T00:24:10.201Z"
}

这个拦截器能覆盖进入 Controller 之后抛出的 403,但有一个边界要注意:如果全局 Guard 在进入拦截器之前就拒绝了请求,某些场景下拦截器可能拿不到这次异常。因此更稳妥的生产实践是:

  1. 守卫负责抛异常,也可以记录必要的权限上下文,例如 requiredPermissionsmodeuserId
  2. 全局异常过滤器负责统一响应结构,并记录所有 4xx/5xx 的基础请求信息。
  3. 安全审计服务负责沉淀结构化事件,可以写入日志平台、数据库、Kafka 或 SIEM 系统。

如果希望把权限上下文记录得更完整,可以在 PermissionsGuard 中增加审计日志:

// src/common/guards/permissions.guard.ts(片段)
if (!hasPermission) {
    this.logger.warn({
        event: "PERMISSION_DENIED",
        userId: user.sub,
        requiredPermissions: permissions,
        mode,
        path: request.url,
        timestamp: new Date().toISOString(),
    });

    throw new ForbiddenException("权限不足");
}

生产环境还可以进一步把审计日志抽成独立服务,避免每个守卫都直接依赖 Logger

// src/security/audit-log.service.ts
import { Injectable, Logger } from "@nestjs/common";

export interface AuditEvent {
    event: string;
    userId?: number | "anonymous";
    method?: string;
    url?: string;
    requestId?: string;
    metadata?: Record<string, unknown>;
}

@Injectable()
export class AuditLogService {
    private readonly logger = new Logger("AuditLog");

    warn(event: AuditEvent): void {
        this.logger.warn({
            ...event,
            timestamp: new Date().toISOString(),
        });
    }
}

这样 Guard、Service、异常过滤器都可以写入同一套结构化审计日志,后续接入 ELK、Loki、Datadog、Sentry 或安全审计平台时,不需要再改业务代码。

这里是通过拦截器的方式,捕获控制器抛出的 ForbiddenException,记录越权访问尝试。

7.3 权限变更审计

除了记录“谁被拒绝访问”,还必须记录“谁改了权限”。RBAC 系统中真正高风险的操作往往不是访问某个接口,而是修改角色、权限和用户角色关系。

以下操作建议全部进入审计日志:

操作风险
创建、禁用权限点可能改变系统能力边界
修改角色权限可能扩大或收缩一批用户的访问范围
给用户分配角色可能直接授予管理能力
移除用户角色可能影响线上业务操作
清空权限缓存可能导致短时间内权限判断结果变化

以修改角色权限为例,建议同时记录修改前后的权限集合:

// src/system/role.service.ts(片段)
async updateRolePermissions(roleId: number, permissionIds: number[], operatorId: number): Promise<void> {
    const before = await this.prisma.rolePermission.findMany({
        where: { roleId },
        select: { permissionId: true },
    });

    await this.prisma.$transaction(async (tx) => {
        await tx.rolePermission.deleteMany({ where: { roleId } });
        await tx.rolePermission.createMany({
            data: permissionIds.map((permissionId) => ({ roleId, permissionId })),
        });
    });

    const affectedUserRoles = await this.prisma.userRole.findMany({
        where: { roleId },
        select: { userId: true },
    });
    const affectedUserIds = affectedUserRoles.map((item) => item.userId);

    await this.permissionCache.invalidateByUserIds(affectedUserIds);

    this.auditLog.warn({
        event: "ROLE_PERMISSIONS_UPDATED",
        userId: operatorId,
        metadata: {
            roleId,
            before: before.map((item) => item.permissionId),
            after: permissionIds,
        },
    });
}

这里的 operatorId 是当前执行管理操作的管理员 ID,不是被修改权限的用户 ID。审计日志必须能回答三个问题:

谁改的?改了什么?什么时候改的?

如果是多租户系统,还要额外记录 tenantId,否则后期排查跨租户越权问题会非常困难。

7.4 生产环境注意事项

RBAC 的异常和审计设计,最终目标不是“报错好看”,而是让系统在出问题时可追踪、可定位、可止损。落地时建议遵循以下规则:

  1. 对外统一:所有权限失败都返回统一信封结构,例如 { code, message, data, requestId }
  2. 对外克制:不要返回具体缺失的权限点、角色名、策略条件。
  3. 对内详细:日志中记录用户、接口、权限点、资源 ID、请求 ID、IP、User-Agent。
  4. 高危操作留痕:角色授权、权限禁用、用户角色变更必须记录操作人和变更前后内容。
  5. 日志避免敏感数据:不要记录 token、密码、完整手机号、身份证号等敏感字段。
  6. 异常和审计分层:过滤器负责响应结构,守卫和 Service 负责提供权限上下文,审计服务负责统一落盘或上报。

[/hide]

必应每日一图合集:本周,全世界都在追英仙座流星雨… | 2026 第 33 周

作者 Kevin
2026年8月16日 09:13

必应每日一图第 33 周合集(2026.08.10 ~ 2026.08.16),共 7 张精选壁纸。本周带你探访了约书亚树国家公园(两片沙漠交汇之地)、哥本哈根新港运河沿岸的彩色房屋(绚丽多彩的哥本哈根)、安博塞利国家公园的非洲草原象群(值得守护的巨兽)、泰德天文台上空的英仙座流星(许个愿吧)、扎克舒夫附近的野生动物通道(为动物脚掌而建,而非行人)、圣胡安县阿什斯利帕荒野地的奇岩柱(绝妙的平衡术)以及戈尔韦郡罗斯埃里利方济各会修道院遗址(天鹅开启传奇之处)。

图片版权归原作者及 Bing 所有,仅限个人收藏或壁纸使用,4K 高清原图附后。

周日 8 月 16 日 | 戈尔韦郡罗斯埃里利方济各会修道院遗址,康诺特省,爱尔兰

天鹅开启传奇之处 | 戈尔韦郡罗斯埃里利方济各会修道院遗址,康诺特省,爱尔兰 (© Maria Janus/Shutterstock) - 2026/08/16[ 阅读全文 ]

原文链接: https://www.shephe.com/post/bing-wallpaper-collection-2026-week-33/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

10 个 Photoshop AI 对象选择工具的神操作,抠图快到没朋友

作者 Kevin
2026年8月16日 09:08

你肯定遇到过这种情况——一张人像,其他地方都修好了,就卡在选区上。模特的头发丝随风飘散,浅色发丝和亮色背景混在一起,套索勾了半天,一放大全是锯齿。或者是一堆产品散落在桌面上,要一个一个抠出来,抠到怀疑人生。

以前碰上这种复杂边缘,基本就是三板斧:钢笔工具勾路径(耐心得好)、通道抠图(色阶拉到极限再慢慢涂)、或者套索加羽化碰运气。碰上树叶缝隙那种满屏小洞的图,简直想摔数位板。不是不能做,是太费时间了——修一张图,一半时间花在选区和蒙版上。

Photoshop 的 AI 对象选择工具(Object Selection Tool)就是来终结这个痛点的。它不是简单的"一键抠图",而是基于 Adobe 的 AI 引擎,能识别图片中的具体对象——人、衣服、头发、眼镜、帽子——甚至能同时选中多个属性。而且随着 Photoshop 2026 的更新,它的识别精度一直在提升。不过有一点需要说明:对象选择工具的核心 AI 计算依赖云端处理,所以需要联网才能获得最佳效果。

下面这 10 个技巧,从基础设置到高阶玩法,帮你把这个工具真正榨干。

1. 开启云端处理,提升 AI 识别精度

对象选择工具的 AI 有两个模式:本地处理和云端处理。默认可能是本地模式,但想要最好的效果,一定要切到云端。

进入 编辑 > 首选项 > 图像处理,在图像处理模式下拉菜单中,选择 云端(云彩)

[ 阅读全文 ]

原文链接: https://www.shephe.com/tutorial/photoshop-object-selection-tool-tips/
版权声明:「像素工坊」版权所有,转载请用明链标明本文地址
本站相关: 随机文章 | 站长微博 | 关于本站 | 联系站长 | 捐助作者

15 NestJS 统一响应体设计(信封模式)

作者 木灵鱼儿
2026年8月16日 08:09

NestJS 统一响应体设计:信封格式与正确 HTTP 状态码

在前后端分离项目中,API 响应格式会同时影响前端开发体验、错误处理和系统可观测性。本文以 NestJS 为例,设计一套“信封格式 + 正确 HTTP 状态码”的统一响应方案,覆盖成功响应、异常响应和 Swagger 文档三个部分。

本文默认读者已经了解 NestJS 的拦截器、异常过滤器,以及上一篇《NestJS 生产级错误过滤方案》中的 Prisma 过滤器链路。


[hide]

一、先确定响应格式

1.1 两种常见方案

业界主要有两种 API 响应风格。

HTTP 语义派(Stripe、GitHub、Google API):

  • 成功请求返回 HTTP 2xx,body 直接是数据;
  • 失败请求返回 HTTP 4xx/5xx,body 是结构化错误信息;
  • 代理、网关、监控系统可以直接根据 HTTP 状态码判断请求是否成功。

统一信封派

  • 成功和失败都返回 HTTP 200;
  • 前端通过 body 中的 code 字段判断业务结果;
  • 前端拦截器实现简单,但基础设施层无法准确感知失败请求。

1.2 本文采用的折中方案

本文保留信封格式,同时使用正确的 HTTP 状态码:

// 成功:HTTP 200
{ "code": 0, "message": "success", "data": { "id": 1 } }

// 失败:HTTP 404
{ "code": 40402, "message": "用户不存在", "data": null }

这样,前端可以统一读取 codemessagedata,Nginx、网关、Prometheus 等基础设施也仍然可以根据 HTTP 状态码进行统计和告警。

需要特别区分两个字段:

  • HTTP 状态码:表示 HTTP 层面的请求结果;
  • code:表示业务层面的结果,成功固定为 0,失败使用数字业务码。

1.3 这套方案需要改造什么

目标实现方式
统一成功响应全局响应拦截器
统一错误响应异常过滤器返回信封格式
统一业务错误码数字枚举集中管理
正确展示泛型响应自定义 Swagger 响应装饰器
避免重复声明公共错误Swagger 文档后处理器

Controller 和 Service 不需要手动调用 success()failed()。正常返回值由拦截器包装,异常则由过滤器处理。


二、目录结构与实现顺序

建议按下面的顺序落地:先定义响应模型,再接入运行时组件,最后补齐 Swagger 描述。

src/
├── main.ts
├── app.module.ts
├── common/
│   ├── dto/
│   │   └── api-response.dto.ts
│   ├── decorators/
│   │   ├── skip-transform.decorator.ts
│   │   ├── api-object-response.decorator.ts
│   │   ├── api-array-response.decorator.ts
│   │   └── api-paginated-response.decorator.ts
│   ├── exceptions/
│   │   ├── business.exception.ts
│   │   └── error-codes.ts
│   ├── filters/
│   │   ├── all-exceptions.filter.ts
│   │   └── prisma-exception.filter.ts
│   ├── interceptors/
│   │   └── transform.interceptor.ts
│   └── swagger/
│       └── inject-global-errors.ts
└── users/
    ├── dto/
    │   └── create-user.dto.ts
    ├── entities/
    │   └── user.entity.ts
    └── users.controller.ts

三、定义统一响应模型

3.1 通用响应 DTO

ApiResponseDto<T> 同时表示成功和失败响应。普通对象和数组可以复用同一个类,区别只在于泛型参数是 T 还是 T[]

// src/common/dto/api-response.dto.ts
import { ApiProperty } from "@nestjs/swagger";

export class ApiResponseDto<T> {
    @ApiProperty({ example: 0, description: "业务状态码,0 表示成功" })
    code: number;

    @ApiProperty({ example: "success" })
    message: string;

    data: T | null;

    readonly requestId?: string; // 可选字段,便于追踪请求

    static success<T>(data: T, message = "success"): ApiResponseDto<T> {
        const response = new ApiResponseDto<T>();
        response.code = 0;
        response.message = message;
        response.data = data ?? null;
        return response;
    }

    static failed(code: number, message: string, requestId?: string): ApiResponseDto<never> {
        const response = new ApiResponseDto<never>();
        response.code = code;
        response.message = message;
        response.data = null;
        if (requestId) response.requestId = requestId;
        return response;
    }
}

这里使用 data ?? null,可以把 undefined 统一转换为 null。例如删除接口没有返回值时,响应仍然保持稳定:

{ "code": 0, "message": "success", "data": null }

3.2 分页响应 DTO

分页响应的 data 不是任意类型,而是包含列表和分页元数据的对象,因此需要单独定义基础 DTO:

// src/common/dto/api-response.dto.ts
export class PaginatedResponseDto<T> {
    @ApiProperty({ example: 0 })
    code: number;

    @ApiProperty({ example: "success" })
    message: string;

    data: {
        items: T[];
        total: number;
        page: number;
        pageSize: number;
        totalPages: number;
    };
}

TypeScript 泛型只在编译期存在。运行时的 ApiResponseDto<UserEntity> 仍然只是 ApiResponseDto,所以后面的 Swagger 装饰器必须手动描述 data 的具体结构。


四、统一管理业务错误码

建议使用数字枚举,并让错误码的前缀与 HTTP 状态码保持一致。规则可以定义为:HTTP 状态码 * 100 + 序号

// src/common/exceptions/error-codes.ts
export enum ErrorCode {
    SUCCESS = 0,

    BAD_REQUEST = 40001,
    VALIDATION_FAILED = 40002,

    UNAUTHORIZED = 40101,
    TOKEN_EXPIRED = 40102,
    TOKEN_INVALID = 40103,
    INVALID_CREDENTIALS = 40104,

    FORBIDDEN = 40301,
    PERMISSION_DENIED = 40302,

    NOT_FOUND = 40401,
    USER_NOT_FOUND = 40402,
    RESOURCE_NOT_FOUND = 40403,

    CONFLICT = 40901,
    USER_ALREADY_EXISTS = 40902,
    RESOURCE_CONFLICT = 40903,

    INTERNAL_ERROR = 50001,
}
错误码范围含义
0成功
40001 ~ 40099通用请求错误
40101 ~ 40199认证错误
40301 ~ 40399权限错误
40401 ~ 40499资源不存在
40901 ~ 40999冲突错误
50001 ~ 50099服务器内部错误

业务错误码应集中维护,避免在 Controller 或 Service 中散落魔法数字。前端也可以根据 code 做国际化映射。

4.1 业务异常基类

业务异常继承 HttpException,因此仍然可以由全局异常过滤器统一处理:

// src/common/exceptions/business.exception.ts
import { HttpException, HttpStatus } from "@nestjs/common";
import { ErrorCode } from "./error-codes";

export class BusinessException extends HttpException {
    constructor(
        message: string,
        statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
        public readonly code: number = ErrorCode.BAD_REQUEST,
    ) {
        super({ message, code }, statusCode);
    }
}

使用时,HTTP 状态码和业务错误码同时指定:

throw new BusinessException("该邮箱已被注册", HttpStatus.CONFLICT, ErrorCode.USER_ALREADY_EXISTS);

五、统一包装成功响应

5.1 跳过包装的装饰器

文件下载、流式响应等内容不是 JSON,不能套用信封格式。用元数据提供显式的排除开关:

// src/common/decorators/skip-transform.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const SKIP_TRANSFORM_KEY = "skipTransform";

export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM_KEY, true);

5.2 TransformInterceptor

拦截器只负责包装正常返回值,不处理业务判断,也不捕获异常。异常会进入异常过滤器链路。

// src/common/interceptors/transform.interceptor.ts
import {
    CallHandler,
    ExecutionContext,
    Injectable,
    NestInterceptor,
    StreamableFile,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { ApiResponseDto } from "../dto/api-response.dto";
import { SKIP_TRANSFORM_KEY } from "../decorators/skip-transform.decorator";

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

    intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
        const skip = this.reflector.getAllAndOverride<boolean>(SKIP_TRANSFORM_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);

        if (skip) return next.handle();

        // NestJS 中 @Post() 默认返回 201,统一覆盖为 200,与信封格式保持一致
        context.switchToHttp().getResponse().status(200);

        return next.handle().pipe(
            map((data) => {
                if (data instanceof StreamableFile) return data;
                return ApiResponseDto.success(data);
            }),
        );
    }
}

文件下载接口使用 @SkipTransform()

@Get("export")
@SkipTransform()
exportFile(): StreamableFile {
    // 返回文件流
}

5.3 注册全局拦截器

推荐在模块中注册,这样 NestJS 可以注入 Reflector 及其他依赖:

// src/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 {}

也可以在 main.ts 中手动实例化,但这种方式需要自行传入依赖:

app.useGlobalInterceptors(new TransformInterceptor(new Reflector()));

5.4 边界行为

场景处理方式
返回 undefined转为 data: null
返回 StreamableFile直接透传
文件下载使用 @SkipTransform()
SSE / WebSocket不属于普通 HTTP JSON 响应,按专用协议处理

六、让异常过滤器返回同一种信封

成功响应由拦截器包装,异常响应不会经过 map(),因此必须由异常过滤器直接构造信封。

6.1 AllExceptionsFilter 的处理原则

上一篇文章中的过滤器链路可以保持不变:Prisma 过滤器优先处理 Prisma 异常,非 Prisma 异常通过 throw exception 继续交给 AllExceptionsFilter。本篇只改造过滤器最终返回的 body。

对于 HttpException,需要从 exception.getResponse() 中读取业务异常携带的 code;没有业务码时,根据 HTTP 状态码生成默认业务码。

// src/common/filters/all-exceptions.filter.ts(关键方法)
private handleHttpException(
    exception: HttpException,
    response: Response,
    meta: { path: string; requestId?: string },
) {
    const statusCode = exception.getStatus();
    const exceptionResponse = exception.getResponse();

    let message: string;
    let code: number | undefined;

    if (typeof exceptionResponse === "string") {
        message = exceptionResponse;
    } else if (typeof exceptionResponse === "object" && exceptionResponse !== null) {
        const body = exceptionResponse as Record<string, unknown>;
        message = typeof body.message === "string" ? body.message : exception.message;
        code = typeof body.code === "number" ? body.code : undefined;
    } else {
        message = exception.message;
    }

    const responseBody = ApiResponseDto.failed(
        code ?? this.getDefaultCode(statusCode),
        message,
    );

    if (meta.requestId) responseBody.requestId = meta.requestId;
    response.status(statusCode).json(responseBody);
}

private handleUnknownError(
    exception: unknown,
    response: Response,
    meta: { path: string; requestId?: string },
    isProduction: boolean,
) {
    const message = exception instanceof Error ? exception.message : String(exception);
    const errorMessage = isProduction ? "服务器内部错误,请稍后重试" : message;
    const responseBody = ApiResponseDto.failed(ErrorCode.INTERNAL_ERROR, errorMessage);

    if (meta.requestId) responseBody.requestId = meta.requestId;
    response.status(HttpStatus.INTERNAL_SERVER_ERROR).json(responseBody);
}

private getDefaultCode(statusCode: number): number {
    const map: Record<number, number> = {
        400: ErrorCode.BAD_REQUEST,
        401: ErrorCode.UNAUTHORIZED,
        403: ErrorCode.FORBIDDEN,
        404: ErrorCode.NOT_FOUND,
        409: ErrorCode.CONFLICT,
        500: ErrorCode.INTERNAL_ERROR,
        503: ErrorCode.INTERNAL_ERROR,
    };

    return map[statusCode] ?? ErrorCode.INTERNAL_ERROR;
}

实际项目中仍应在过滤器里记录 path、时间、堆栈和请求 ID。这里省略日志代码,只展示响应结构的关键变化。

6.2 PrismaExceptionFilter 的处理方式

Prisma 过滤器仍然只负责识别和翻译 Prisma 错误,非 Prisma 异常继续抛出。返回响应时改用 ApiResponseDto.failed()

// src/common/filters/prisma-exception.filter.ts(关键分支)
case "P2002": {
    const fields = extractUniqueConstraintFields(exception);
    const label = fields.length > 0 ? fields.join(", ") : "字段";

    return response.status(HttpStatus.CONFLICT).json(
        ApiResponseDto.failed(
            ErrorCode.CONFLICT,
            `${label} 已存在,请使用其他值`,
        ),
    );
}

case "P2025":
    return response.status(HttpStatus.NOT_FOUND).json(
        ApiResponseDto.failed(
            ErrorCode.NOT_FOUND,
            "请求的记录不存在",
        ),
    );

其余 Prisma 错误码按相同规则映射:选择合适的 HTTP 状态码、选择对应的业务错误码、记录详细日志、返回对用户友好的消息。不要把 Prisma 原始错误信息直接放进生产响应。


七、Swagger 正确描述泛型响应

运行时泛型会被擦除,Swagger 无法自动知道 ApiResponseDto<UserEntity>data 是什么类型。因此需要组合使用:

  • ApiExtraModels:把 DTO 和业务模型加入 Swagger 的全局 Schema;
  • getSchemaPath:获取某个模型的 $ref 路径;
  • allOf:复用信封基础结构,并覆盖 data 字段。

7.1 对象响应装饰器

// src/common/decorators/api-object-response.decorator.ts
import { Type, applyDecorators } from "@nestjs/common";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { ApiResponseDto } from "../dto/api-response.dto";

export const ApiObjectResponse = <TModel extends Type>(model: TModel, status = 200) =>
    applyDecorators(
        ApiExtraModels(ApiResponseDto, model),
        ApiOkResponse({
            status,
            schema: {
                title: `${model.name}Response`,
                allOf: [
                    { $ref: getSchemaPath(ApiResponseDto) },
                    { properties: { data: { $ref: getSchemaPath(model) } } },
                ],
            },
        }),
    );

7.2 数组响应装饰器

数组不需要单独的响应类,只需把 data 描述成数组:

// src/common/decorators/api-array-response.decorator.ts
import { Type, applyDecorators } from "@nestjs/common";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { ApiResponseDto } from "../dto/api-response.dto";

export const ApiArrayResponse = <TModel extends Type>(model: TModel) =>
    applyDecorators(
        ApiExtraModels(ApiResponseDto, model),
        ApiOkResponse({
            schema: {
                title: `${model.name}ArrayResponse`,
                allOf: [
                    { $ref: getSchemaPath(ApiResponseDto) },
                    {
                        properties: {
                            data: {
                                type: "array",
                                items: { $ref: getSchemaPath(model) },
                            },
                        },
                    },
                ],
            },
        }),
    );

7.3 分页响应装饰器

分页 data 需要同时描述 items 和分页字段,因此引用 PaginatedResponseDto

// src/common/decorators/api-paginated-response.decorator.ts
import { Type, applyDecorators } from "@nestjs/common";
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from "@nestjs/swagger";
import { PaginatedResponseDto } from "../dto/api-response.dto";

export const ApiPaginatedResponse = <TModel extends Type>(model: TModel) =>
    applyDecorators(
        ApiExtraModels(PaginatedResponseDto, model),
        ApiOkResponse({
            schema: {
                title: `Paginated${model.name}Response`,
                allOf: [
                    { $ref: getSchemaPath(PaginatedResponseDto) },
                    {
                        properties: {
                            data: {
                                type: "object",
                                properties: {
                                    items: {
                                        type: "array",
                                        items: { $ref: getSchemaPath(model) },
                                    },
                                    total: { type: "number", example: 100 },
                                    page: { type: "number", example: 1 },
                                    pageSize: { type: "number", example: 20 },
                                    totalPages: { type: "number", example: 5 },
                                },
                            },
                        },
                    },
                ],
            },
        }),
    );

7.4 Controller 中的使用方式

@Post()
@ApiObjectResponse(UserEntity, 201)
create(@Body() dto: CreateUserDto): Promise<UserEntity> {
    return this.usersService.create(dto);
}

@Get()
@ApiArrayResponse(UserEntity)
findAll(): Promise<UserEntity[]> {
    return this.usersService.findAll();
}

@Get(":id")
@ApiObjectResponse(UserEntity)
findOne(@Param("id", ParseIntPipe) id: number): Promise<UserEntity> {
    return this.usersService.findOne(id);
}

7.5 开启 Swagger CLI 插件

CLI 插件可以从 DTO 类型、JSDoc 注释和 class-validator 装饰器推断字段级 Schema:

{
    "collection": "@nestjs/schematics",
    "sourceRoot": "src",
    "compilerOptions": {
        "plugins": [
            {
                "name": "@nestjs/swagger",
                "options": {
                    "classValidatorShim": true,
                    "introspectComments": true
                }
            }
        ]
    }
}

插件只能补充 DTO 和 Entity 的字段信息,不能推断 Controller 的泛型响应,因此 @ApiObjectResponse@ApiArrayResponse 等装饰器仍然需要保留。

使用 SWC 时,CLI Plugin 的 AST 转换不会由 SWC 自动执行。可以使用 nest start -b swc --type-check,让 SWC 负责编译,同时由 TypeScript 处理类型检查和元数据生成。

7.6 全局注入公共错误响应

401、403、500 等公共错误不必在每个接口上重复声明,可以在生成文档后统一注入:

// src/common/swagger/inject-global-errors.ts
import { OpenAPIObject } from "@nestjs/swagger";

const GLOBAL_ERROR_RESPONSES = {
    "401": {
        description: "未授权,Token 无效或过期",
        content: {
            "application/json": {
                schema: {
                    properties: {
                        code: { type: "number", example: 40101 },
                        message: { type: "string", example: "Token 已过期,请重新登录" },
                        data: { nullable: true, example: null },
                    },
                },
            },
        },
    },
    "403": {
        description: "权限不足",
        content: {
            "application/json": {
                schema: {
                    properties: {
                        code: { type: "number", example: 40301 },
                        message: { type: "string", example: "权限不足,无法执行此操作" },
                        data: { nullable: true, example: null },
                    },
                },
            },
        },
    },
    "500": {
        description: "服务器内部错误",
        content: {
            "application/json": {
                schema: {
                    properties: {
                        code: { type: "number", example: 50001 },
                        message: { type: "string", example: "服务器内部错误,请稍后重试" },
                        data: { nullable: true, example: null },
                    },
                },
            },
        },
    },
} as const;

export function injectGlobalErrors(document: OpenAPIObject): OpenAPIObject {
    for (const pathItem of Object.values(document.paths)) {
        for (const operation of Object.values(pathItem)) {
            if (operation && typeof operation === "object" && "responses" in operation) {
                Object.assign(operation.responses, GLOBAL_ERROR_RESPONSES);
            }
        }
    }

    return document;
}

main.ts 中包裹 SwaggerModule.createDocument()

const documentFactory = () => {
    const document = SwaggerModule.createDocument(app, config);
    return injectGlobalErrors(document);
};

SwaggerModule.setup("docs", app, documentFactory);

业务特有的 404、409 等错误,仍建议在具体接口上单独声明,以便文档准确反映接口行为。


八、完整请求链路

8.1 成功请求

Controller.findOne(1)
    → UsersService.findOne(1)
    → 返回 UserEntity
    → TransformInterceptor.map()
    → HTTP 200 { code: 0, message: "success", data: UserEntity }

8.2 业务异常

UsersService.findOne(999)
    → throw BusinessException(..., 404, ErrorCode.USER_NOT_FOUND)
    → PrismaExceptionFilter 判断不是 Prisma 错误,继续抛出
    → AllExceptionsFilter 读取 code 和 message
    → HTTP 404 { code: 40402, message: "用户不存在", data: null }

8.3 Prisma 异常

prisma.user.create() → P2002
    → PrismaExceptionFilter 将其转换为 HTTP 409
    → 返回 { code: 40901, message: "字段已存在,请使用其他值", data: null }

8.4 无返回值接口

UsersService.remove(1) → undefined
    → TransformInterceptor 将 undefined 转为 null
    → HTTP 200 { code: 0, message: "success", data: null }

[/hide]

14 NestJS 生产级错误过滤方案

作者 木灵鱼儿
2026年8月16日 06:23

前言:为什么需要生产级错误过滤

默认响应的局限

NestJS 内置的异常处理机制已经相当完善,但在生产项目中仍然存在明显不足:

格式不统一:不同层抛出的异常,响应结构各不相同。HttpException 返回 { statusCode, message },未捕获的 Error 返回 500 的通用格式,Prisma 错误则直接泄露为 500 且没有任何业务上下文。

泄露内部细节:默认情况下,数据库错误、堆栈信息、内部路径等敏感内容可能出现在生产环境的响应体中。

可观测性差:没有统一的日志格式,无法关联请求链路,难以在监控系统中定位问题根源。

生产环境的核心诉求

诉求说明
统一格式前端对接时只需处理一种响应结构
安全隐藏内部细节生产环境不暴露数据库错误码、堆栈、文件路径
可观测性每条错误日志可追溯到具体请求
可维护性错误码集中管理,便于国际化和前端对接

本文方案概览

本文构建两层过滤器链路:

请求进入
   ↓
业务逻辑 / ORM 操作
   ↓ 抛出异常
PrismaExceptionFilter     ← 优先处理 Prisma 错误,非 Prisma 错误继续上抛
   ↓ 非 Prisma 错误
AllExceptionsFilter        ← 兜底处理所有其余异常(HttpException、业务异常、未知 Error)
   ↓
统一格式的 JSON 响应

涉及的文件结构:

src/
├── common/
│   ├── filters/
│   │   ├── all-exceptions.filter.ts        # 兜底过滤器
│   │   └── prisma-exception.filter.ts      # Prisma 专用过滤器
│   ├── exceptions/
│   │   ├── business.exception.ts           # 自定义业务异常基类
│   │   └── error-codes.ts                  # 统一错误码枚举
│   └── interfaces/
│       └── error-response.interface.ts     # 统一响应结构接口
└── database/
    └── utils/
        └── prisma-error.util.ts            # Prisma 错误判断工具函数

[hide]

二、统一响应格式设计

2.1 先回答一个问题:错误格式要和成功格式统一吗?

很多项目的成功响应采用"信封"结构:

{ "code": 0, "message": "success", "data": { "id": 1 } }

自然会有人问:错误响应是否也应该套同一层信封?这是一个架构决策,业界存在两种流派。

流派一:统一信封,HTTP 状态码始终 200

// 成功
{ "code": 0, "message": "success", "data": { "id": 1 } }

// 失败(HTTP 200)
{ "code": 40401, "message": "用户不存在", "data": null }

优点:前端只需一个响应拦截器,统一判断 code !== 0 即为错误。国内对接微信小程序的项目偏好这种方式。

缺点:滥用 HTTP 200 会让代理、网关、监控系统(Nginx 访问日志、Prometheus)无法通过状态码区分成功和失败,运维可观测性大幅下降。

流派二:HTTP 语义,成功和失败结构不同(本文方案)

// 成功 HTTP 200,body 直接是数据
{ "id": 1, "email": "user@example.com" }

// 失败 HTTP 4xx/5xx,body 是结构化错误体
{ "statusCode": 404, "error": "Not Found", "message": "用户不存在" }

优点:HTTP 状态码有原生语义,监控告警、负载均衡、CDN 都能直接感知;符合 RESTful 规范,与 Stripe、GitHub、Google API 的设计一致。

折中方案:信封 + 正确的 HTTP 状态码并存

如果团队有强烈的统一信封需求,推荐的做法是两者并存,而不是牺牲 HTTP 语义:

// 失败 HTTP 404(状态码仍然正确)
{ "code": 40401, "message": "用户不存在", "data": null }

// 成功 HTTP 200
{ "code": 0, "message": "success", "data": { "id": 1 } }

这样既满足前端统一解析,又不破坏基础设施层的感知能力。本文过滤器实现中,只需将 response.status(xxx).json(body)body 替换为信封格式,过滤器主体逻辑无需任何改动。

本文结论:采用流派二,不强行统一成功和失败结构。理由是:成功响应 data 字段的形态千变万化,强行套一层信封并不能简化前端逻辑;而错误响应只需保证自身结构一致即可。如果项目已有信封规范,按折中方案适配即可。


2.2 错误响应接口定义

定义整个项目的错误响应接口,所有异常过滤器都应遵循此结构:

// src/common/interfaces/error-response.interface.ts
export interface ErrorResponse {
    statusCode: number; // HTTP 状态码
    error: string; // 错误类型(如 "Not Found")
    message: string; // 对用户友好的提示
    code?: string; // 业务/数据库错误码(仅开发环境)
    requestId?: string; // 请求追踪 ID
    timestamp: string; // ISO 时间戳
    path: string; // 请求路径
}

字段设计意图

  • statusCode + error:与 HTTP 标准对齐,前端可直接用状态码分支处理
  • message:面向用户的提示,不含技术细节
  • code:业务错误码,开发环境辅助调试,生产环境应隐藏
  • requestId:与日志系统关联,方便问题溯源
  • timestamp + path:快速定位问题发生的时间和接口

环境差异

字段开发环境生产环境
code返回(含 Prisma 错误码)隐藏
stack可附加严格隐藏
message较详细友好简洁

三、自定义业务异常

3.1 业务异常基类

NestJS 内置的 HttpException 足以处理 HTTP 层面的错误,但无法携带业务语义(如错误码)。封装一个 BusinessException 基类:

// src/common/exceptions/business.exception.ts
import { HttpException, HttpStatus } from "@nestjs/common";

export class BusinessException extends HttpException {
    constructor(
        message: string,
        statusCode: HttpStatus = HttpStatus.BAD_REQUEST,
        public readonly code?: string,
    ) {
        super({ message, code }, statusCode);
    }
}

继承 HttpException 的好处:AllExceptionsFilter 中可以用 instanceof HttpException 统一捕获,而不需要为 BusinessException 单独分支。

3.2 统一错误码枚举

// src/common/exceptions/error-codes.ts
export enum ErrorCode {
    // 用户相关
    USER_NOT_FOUND = "USER_NOT_FOUND",
    USER_ALREADY_EXISTS = "USER_ALREADY_EXISTS",
    INVALID_CREDENTIALS = "INVALID_CREDENTIALS",

    // 权限相关
    PERMISSION_DENIED = "PERMISSION_DENIED",
    TOKEN_EXPIRED = "TOKEN_EXPIRED",
    TOKEN_INVALID = "TOKEN_INVALID",

    // 资源相关
    RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND",
    RESOURCE_CONFLICT = "RESOURCE_CONFLICT",
}

枚举集中管理有两个好处:前端可以直接基于 code 字段做国际化映射;后端重构时修改一处即可全局生效。

3.3 使用示例

// 在 Service 中抛出业务异常
import { BusinessException } from "../common/exceptions/business.exception";
import { ErrorCode } from "../common/exceptions/error-codes";

// 直接抛出 HttpException(简单场景,无需错误码)
throw new NotFoundException("用户不存在");

// 抛出业务异常(需要错误码的场景)
throw new BusinessException("该邮箱已被注册", HttpStatus.CONFLICT, ErrorCode.USER_ALREADY_EXISTS);

四、Prisma 错误工具函数

Prisma 没有提供统一的错误基类,各错误类型独立存在。创建工具函数封装这些判断逻辑:

// src/database/utils/prisma-error.util.ts
import { Prisma } from "../../generated/prisma/client";

/** 判断是否为任意 Prisma 错误 */
export function isPrismaError(error: unknown): boolean {
    return (
        error instanceof Prisma.PrismaClientKnownRequestError ||
        error instanceof Prisma.PrismaClientUnknownRequestError ||
        error instanceof Prisma.PrismaClientRustPanicError ||
        error instanceof Prisma.PrismaClientInitializationError ||
        error instanceof Prisma.PrismaClientValidationError
    );
}

/** 判断是否为特定错误码的 Prisma 已知错误 */
export function isPrismaErrorWithCode(
    error: unknown,
    code: string,
): error is Prisma.PrismaClientKnownRequestError {
    return error instanceof Prisma.PrismaClientKnownRequestError && error.code === code;
}

/** 提取唯一约束冲突的字段名列表(P2002) */
export function extractUniqueConstraintFields(
    error: Prisma.PrismaClientKnownRequestError,
): string[] {
    if (error.code === "P2002" && error.meta?.target) {
        return Array.isArray(error.meta.target)
            ? (error.meta.target as string[])
            : [error.meta.target as string];
    }
    return [];
}

/** 获取调试用的详细错误消息(不应出现在生产响应中) */
export function getPrismaErrorMessage(error: unknown): string {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
        return `[${error.code}] ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientValidationError) {
        return `ValidationError: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientInitializationError) {
        return `InitializationError: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientRustPanicError) {
        return `RustPanicError: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientUnknownRequestError) {
        return `UnknownRequestError: ${error.message}`;
    }
    return "Unknown error";
}

将这些判断封装为工具函数而不是散落在过滤器中,好处是:过滤器逻辑更清晰,且工具函数可在 Service 层复用。


五、Prisma 专用异常过滤器

5.1 五种错误类型处理策略

错误类型HTTP 状态码处理要点
PrismaClientKnownRequestError按错误码映射覆盖 P2002/P2003/P2025/P2014/P2011/P2024/P2034 等
PrismaClientValidationError400隐藏内部验证细节,只告知"参数格式错误"
PrismaClientInitializationError503记录完整堆栈,上报监控
PrismaClientRustPanicError500记录后调用 process.exit(1) 触发 PM2 重启
PrismaClientUnknownRequestError500记录完整日志,返回通用 500

5.2 过滤器实现

// src/common/filters/prisma-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from "@nestjs/common";
import { Response, Request } from "express";
import { Prisma } from "../../generated/prisma/client";
import {
    isPrismaError,
    extractUniqueConstraintFields,
} from "../../database/utils/prisma-error.util";

@Catch()
export class PrismaExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(PrismaExceptionFilter.name);

    catch(exception: unknown, host: ArgumentsHost) {
        // 仅处理 Prisma 错误,其他错误继续向上传递给兜底过滤器
        if (!isPrismaError(exception)) {
            throw exception;
        }

        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();
        const request = ctx.getRequest<Request>();
        const path = request.url;
        const timestamp = new Date().toISOString();

        if (exception instanceof Prisma.PrismaClientKnownRequestError) {
            return this.handleKnownRequestError(exception, response, path, timestamp);
        }
        if (exception instanceof Prisma.PrismaClientValidationError) {
            return this.handleValidationError(exception, response, path, timestamp);
        }
        if (exception instanceof Prisma.PrismaClientInitializationError) {
            return this.handleInitializationError(exception, response, path, timestamp);
        }
        if (exception instanceof Prisma.PrismaClientRustPanicError) {
            return this.handleRustPanicError(exception, response, path, timestamp);
        }
        // PrismaClientUnknownRequestError
        return this.handleUnknownRequestError(exception, response, path, timestamp);
    }

    private handleKnownRequestError(
        exception: Prisma.PrismaClientKnownRequestError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        const { code, meta } = exception;
        this.logger.warn(`Prisma Known Error [${code}]: ${exception.message}`, { meta });

        const base = { timestamp, path, code };

        switch (code) {
            case "P2002": {
                const fields = extractUniqueConstraintFields(exception);
                const label = fields.length > 0 ? fields.join(", ") : "字段";
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: `${label} 已存在,请使用其他值`,
                    ...base,
                });
            }
            case "P2025":
                return response.status(HttpStatus.NOT_FOUND).json({
                    statusCode: HttpStatus.NOT_FOUND,
                    error: "Not Found",
                    message: "请求的记录不存在",
                    ...base,
                });
            case "P2003": {
                const field = (meta?.field_name as string) || "关联字段";
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `关联记录不存在(${field})`,
                    ...base,
                });
            }
            case "P2014":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "无法操作,存在关联数据",
                    ...base,
                });
            case "P2011": {
                const constraint = (meta?.constraint as string) || "必填字段";
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `${constraint} 不能为空`,
                    ...base,
                });
            }
            case "P2024":
                return response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
                    statusCode: HttpStatus.SERVICE_UNAVAILABLE,
                    error: "Service Unavailable",
                    message: "数据库连接池超时,请稍后重试",
                    ...base,
                });
            case "P2034":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "事务冲突,请重试",
                    ...base,
                });
            default:
                this.logger.error(`Unhandled Prisma error code: ${code}`);
                return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
                    statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
                    error: "Internal Server Error",
                    message: "数据库操作失败",
                    ...base,
                });
        }
    }

    private handleValidationError(
        exception: Prisma.PrismaClientValidationError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        // 不暴露内部验证细节,只记录日志
        this.logger.warn(`Prisma Validation Error: ${exception.message}`);
        return response.status(HttpStatus.BAD_REQUEST).json({
            statusCode: HttpStatus.BAD_REQUEST,
            error: "Bad Request",
            message: "请求参数格式错误或缺少必填字段",
            timestamp,
            path,
        });
    }

    private handleInitializationError(
        exception: Prisma.PrismaClientInitializationError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        // 连接级错误,记录完整堆栈并上报监控
        this.logger.error(`Prisma Initialization Error: ${exception.message}`, exception.stack);
        // Sentry.captureException(exception);
        return response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
            statusCode: HttpStatus.SERVICE_UNAVAILABLE,
            error: "Service Unavailable",
            message: "数据库服务暂时不可用,请稍后重试",
            timestamp,
            path,
        });
    }

    private handleRustPanicError(
        exception: Prisma.PrismaClientRustPanicError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        // 引擎崩溃,发送响应后立即退出,由 PM2 / systemd 自动重启
        this.logger.fatal(`Prisma Rust Panic Error: ${exception.message}`, exception.stack);
        // Sentry.captureException(exception);
        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "服务器内部错误,请联系管理员",
            timestamp,
            path,
        });
        // 响应写出后退出,触发进程管理器重启
        process.exit(1);
    }

    private handleUnknownRequestError(
        exception: Prisma.PrismaClientUnknownRequestError,
        response: Response,
        path: string,
        timestamp: string,
    ) {
        this.logger.error(`Prisma Unknown Request Error: ${exception.message}`, exception.stack);
        return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "数据库操作失败",
            timestamp,
            path,
        });
    }
}

5.3 关键设计:throw exception 透传

过滤器开头的这段代码是整个链路正确运转的关键:

if (!isPrismaError(exception)) {
    throw exception; // 透传给下一个过滤器(AllExceptionsFilter)
}

@Catch() 无参数意味着捕获所有异常,但 PrismaExceptionFilter 只应处理 Prisma 错误。通过主动 throw 将非 Prisma 异常继续传递,而不是吞掉或返回错误的响应。

@Catch() vs @Catch(Prisma.PrismaClientKnownRequestError) 的取舍

使用 @Catch() 无参数更合适,原因是 Prisma 有五种错误类型,如果用 @Catch(A, B, C, D, E) 虽然可行,但每次 Prisma 增加新的错误类型都需要修改装饰器。无参数 + 内部 isPrismaError 判断更具扩展性。


六、全局兜底异常过滤器

AllExceptionsFilter 处理所有未被 PrismaExceptionFilter 拦截的异常:HttpException(含 BusinessException)、未知 Error,以及任何其他未预期的异常。

// src/common/filters/all-exceptions.filter.ts
import {
    ArgumentsHost,
    Catch,
    ExceptionFilter,
    HttpException,
    HttpStatus,
    Inject,
    Logger,
    Optional,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request, Response } from "express";
import { ErrorResponse } from "../interfaces/error-response.interface";

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
    private readonly logger = new Logger(AllExceptionsFilter.name);

    constructor(
        @Optional()
        @Inject(ConfigService)
        private readonly configService?: ConfigService,
    ) {}

    catch(exception: unknown, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();
        const request = ctx.getRequest<Request>();

        const isProduction = this.configService?.get<string>("NODE_ENV") === "production";

        // 从请求头中读取追踪 ID(由网关或 Nginx 注入)
        const requestId = (request.headers["x-request-id"] as string) || undefined;

        const path = request.url;
        const timestamp = new Date().toISOString();

        if (exception instanceof HttpException) {
            return this.handleHttpException(
                exception,
                response,
                { path, timestamp, requestId },
                isProduction,
            );
        }

        // 未知错误(编程错误、运行时异常等)
        return this.handleUnknownError(
            exception,
            response,
            { path, timestamp, requestId },
            isProduction,
        );
    }

    private handleHttpException(
        exception: HttpException,
        response: Response,
        meta: { path: string; timestamp: string; requestId?: string },
        isProduction: boolean,
    ) {
        const statusCode = exception.getStatus();
        const exceptionResponse = exception.getResponse();

        // HttpException 的 response 可能是字符串或对象
        let message: string;
        let code: string | undefined;

        if (typeof exceptionResponse === "string") {
            message = exceptionResponse;
        } else if (typeof exceptionResponse === "object" && exceptionResponse !== null) {
            const res = exceptionResponse as Record<string, unknown>;
            message = (res.message as string) || exception.message;
            code = res.code as string | undefined;
        } else {
            message = exception.message;
        }

        const body: ErrorResponse = {
            statusCode,
            error: this.getHttpErrorText(statusCode),
            message,
            timestamp: meta.timestamp,
            path: meta.path,
        };

        if (meta.requestId) body.requestId = meta.requestId;

        // code 字段仅在非生产环境返回
        if (!isProduction && code) body.code = code;

        if (statusCode >= 500) {
            this.logger.error(`[${statusCode}] ${meta.path} - ${message}`, exception.stack, {
                requestId: meta.requestId,
            });
        } else if (statusCode >= 400) {
            this.logger.warn(`[${statusCode}] ${meta.path} - ${message}`, { requestId: meta.requestId });
        }

        response.status(statusCode).json(body);
    }

    private handleUnknownError(
        exception: unknown,
        response: Response,
        meta: { path: string; timestamp: string; requestId?: string },
        isProduction: boolean,
    ) {
        const message = exception instanceof Error ? exception.message : String(exception);
        const stack = exception instanceof Error ? exception.stack : undefined;

        this.logger.error(`[500] ${meta.path} - Unhandled exception: ${message}`, stack, {
            requestId: meta.requestId,
        });

        // Sentry.captureException(exception);

        const body: ErrorResponse = {
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            // 生产环境不暴露内部错误信息
            message: isProduction ? "服务器内部错误,请稍后重试" : message,
            timestamp: meta.timestamp,
            path: meta.path,
        };

        if (meta.requestId) body.requestId = meta.requestId;

        // 开发环境附加堆栈,方便调试
        if (!isProduction && stack) {
            (body as Record<string, unknown>).stack = stack;
        }

        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json(body);
    }

    private getHttpErrorText(statusCode: number): string {
        const map: Record<number, string> = {
            400: "Bad Request",
            401: "Unauthorized",
            403: "Forbidden",
            404: "Not Found",
            405: "Method Not Allowed",
            409: "Conflict",
            410: "Gone",
            422: "Unprocessable Entity",
            429: "Too Many Requests",
            500: "Internal Server Error",
            502: "Bad Gateway",
            503: "Service Unavailable",
            504: "Gateway Timeout",
        };
        return map[statusCode] ?? "Error";
    }
}

关键设计点

  • @Optional() 注入 ConfigService:使过滤器在没有 ConfigModule 时也能实例化,降低耦合
  • x-request-id:由反向代理(Nginx/网关)注入,过滤器只读取不生成,保证分布式链路 ID 的一致性
  • 生产环境隐藏 messagestack:未知错误的内部信息绝不对外暴露

七、过滤器注册顺序(链路设计)

NestJS 过滤器的执行规则

NestJS 过滤器遵循后注册先执行的规则:

app.useGlobalFilters(
    new AllExceptionsFilter(configService), // 后执行(先注册)
    new PrismaExceptionFilter(), // 先执行(后注册)
);

执行顺序:

异常抛出
  → PrismaExceptionFilter.catch()
      → Prisma 错误:处理并响应,结束
      → 非 Prisma 错误:throw exception
          → AllExceptionsFilter.catch()
              → HttpException / 未知错误:处理并响应,结束

方式一:在 main.ts 注册(简单场景)

// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ConfigService } from "@nestjs/config";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    const configService = app.get(ConfigService);

    app.useGlobalFilters(new AllExceptionsFilter(configService), new PrismaExceptionFilter());

    await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

适合过滤器不需要额外依赖注入的场景。

方式二:通过 APP_FILTER 注册(支持依赖注入)

当过滤器内部需要注入其他服务时,必须使用模块注册方式:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { AllExceptionsFilter } from "./common/filters/all-exceptions.filter";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

@Module({
    providers: [
        // 注意:APP_FILTER 注册时,先声明的后执行
        // 即:AllExceptionsFilter 先声明 → 后执行(兜底)
        //     PrismaExceptionFilter 后声明 → 先执行
        {
            provide: APP_FILTER,
            useClass: AllExceptionsFilter,
        },
        {
            provide: APP_FILTER,
            useClass: PrismaExceptionFilter,
        },
    ],
})
export class AppModule {}

通过 APP_FILTER 注册的过滤器支持完整的依赖注入,可以在构造函数中注入任何已注册的 Provider。


八、在 Service 层主动捕获特定错误

过滤器是被动兜底,而某些业务场景需要主动控制错误语义。例如:用户注册时唯一冲突应该明确提示"邮箱已被注册",而不是由过滤器输出通用的"字段已存在"。

// src/modules/user/user.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { Prisma } from "../../generated/prisma/client";
import { isPrismaErrorWithCode } from "../../database/utils/prisma-error.util";
import { BusinessException } from "../../common/exceptions/business.exception";
import { ErrorCode } from "../../common/exceptions/error-codes";

@Injectable()
export class UserService {
    constructor(private readonly prisma: PrismaService) {}

    async create(data: Prisma.UserCreateInput) {
        try {
            return await this.prisma.user.create({ data });
        } catch (e) {
            if (isPrismaErrorWithCode(e, "P2002")) {
                // 主动转换为语义明确的业务异常
                throw new BusinessException("该邮箱已被注册", 409, ErrorCode.USER_ALREADY_EXISTS);
            }
            throw e; // 其余错误继续上抛,由过滤器统一处理
        }
    }

    async findOneOrThrow(id: number) {
        const user = await this.prisma.user.findUnique({ where: { id } });
        if (!user) {
            throw new BusinessException(`用户 #${id} 不存在`, 404, ErrorCode.USER_NOT_FOUND);
        }
        return user;
    }

    async remove(id: number) {
        try {
            return await this.prisma.user.delete({ where: { id } });
        } catch (e) {
            if (isPrismaErrorWithCode(e, "P2025")) {
                throw new NotFoundException(`用户 #${id} 不存在`);
            }
            throw e;
        }
    }
}

原则:对于可预期的业务场景(唯一冲突、记录不存在等),在 Service 层主动捕获并转换为语义明确的异常;对于不可预期的错误,直接 throw e 交由过滤器链处理。


九、可观测性:日志与监控接入

日志分级策略

NestJS 内置 Logger 支持五个日志级别,错误处理中的分级建议:

场景级别说明
4xx 客户端错误warn用户输入问题,无需告警
5xx 服务端错误error需要关注,可触发告警
数据库连接失败error需要立即处理
Prisma 引擎崩溃fatal最高优先级,立即告警
// 在过滤器中按严重程度分级
this.logger.warn("4xx 错误");
this.logger.error("5xx 错误", stack);
this.logger.fatal("引擎崩溃", stack);

结构化日志

在生产环境中,推荐结合 nestjs-pinowinston 输出结构化 JSON 日志,便于 ELK、Loki 等日志系统收集和检索:

this.logger.error("数据库错误", {
    requestId,
    path,
    prismaCode: exception.code,
    // 不记录原始 SQL 或敏感数据
});

监控集成预留点

handleRustPanicErrorhandleUnknownError 中,已标注 Sentry 集成点:

// Sentry 集成(取消注释即可启用)
// Sentry.captureException(exception, {
//   extra: { requestId, path },
// });

// OpenTelemetry 集成
// span.setStatus({ code: SpanStatusCode.ERROR, message });
// span.recordException(exception);

实际接入时,只需取消注释并安装对应 SDK,不需要修改过滤器的主体逻辑。


十、完整集成示例

以 User 模块为例,串联整个链路:

10.1 场景一:注册时邮箱重复(P2002 → 409)

POST /users { email: "existing@example.com" }
  → UserService.create()
  → prisma.user.create() 抛出 PrismaClientKnownRequestError(P2002)
  → Service catch → 抛出 BusinessException('该邮箱已被注册', 409, USER_ALREADY_EXISTS)
  → PrismaExceptionFilter.catch() → isPrismaError = false → throw exception
  → AllExceptionsFilter.catch() → instanceof HttpException → 处理
  → 响应:{ statusCode: 409, error: "Conflict", message: "该邮箱已被注册", code: "USER_ALREADY_EXISTS" }

10.2 场景二:查询不存在的用户(主动抛出 BusinessException)

GET /users/999
  → UserService.findOneOrThrow(999)
  → prisma.user.findUnique() → null
  → 抛出 BusinessException('用户 #999 不存在', 404, USER_NOT_FOUND)
  → PrismaExceptionFilter.catch() → isPrismaError = false → throw exception
  → AllExceptionsFilter.catch() → instanceof HttpException → 处理
  → 响应:{ statusCode: 404, error: "Not Found", message: "用户 #999 不存在" }

10.3 场景三:Prisma 唯一冲突未在 Service 层捕获(直接由过滤器处理)

POST /posts { slug: "existing-slug" }
  → PostService.create() → prisma.post.create() 抛出 PrismaClientKnownRequestError(P2002)
  → 未在 Service 层捕获,直接上抛
  → PrismaExceptionFilter.catch() → isPrismaError = true → handleKnownRequestError
  → 响应:{ statusCode: 409, error: "Conflict", message: "slug 已存在,请使用其他值" }

10.4 完整的 UserController + UserService

// src/modules/user/user.controller.ts
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from "@nestjs/common";
import { UserService } from "./user.service";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UserController {
    constructor(private readonly userService: UserService) {}

    @Post()
    create(@Body() dto: CreateUserDto) {
        return this.userService.create(dto);
    }

    @Get(":id")
    findOne(@Param("id", ParseIntPipe) id: number) {
        return this.userService.findOneOrThrow(id);
    }

    @Delete(":id")
    remove(@Param("id", ParseIntPipe) id: number) {
        return this.userService.remove(id);
    }
}

[/hide]

13 NestJS 集成 TypeORM 完全指南

作者 木灵鱼儿
2026年8月16日 05:21

前言

TypeORM 是 Node.js 生态中历史最悠久的 ORM,也是 NestJS 官方文档中首推的数据库解决方案之一。它以装饰器驱动的 Entity 定义为核心,支持 Active Record 与 Data Mapper 两种模式,与 TypeScript 有着天然的契合。

相比 Prisma 的 Schema-first 理念,TypeORM 更贴近传统 ORM 思想:Entity 类既是数据库表的映射,也是业务对象。对于习惯 Java/Spring 或 C#/EF 体系的开发者来说,TypeORM 的上手成本更低。

本文使用 Data Mapper 模式(Repository 模式),这是 NestJS 中更推荐的生产方式。与 Active Record 相比,Repository 模式将数据库操作与业务逻辑分离,测试更友好,依赖关系更清晰。

本文基于 TypeORM 最新版NestJS 最新版,以 MySQL 为主要示例(PostgreSQL 差异处会单独说明),从安装到生产级集成,覆盖以下内容:

  • 生产级目录结构与 data-source.ts 的设计原则
  • ConfigService 整合,异步读取配置,避免硬编码
  • 自定义日志系统,与 NestJS Logger 对接
  • Entity 定义:公共基类、软删除、索引、钩子
  • 数据库迁移(CLI 工作流)
  • Repository 模式 CRUD 与 QueryBuilder 进阶
  • 关联关系(OneToOne / OneToMany / ManyToMany)
  • 事务管理(DataSource.transaction / QueryRunner)
  • 生产级全局异常过滤器
本文假设你已有一个使用 @nestjs/config 的 NestJS 项目。ConfigModule 的配置请参考本系列第 01 篇。

[hide]

一、生产级项目目录结构

在集成 TypeORM 之前,先规划一个符合业界最佳实践的目录结构:

project-root/
├── src/
│   ├── common/
│   │   └── filters/
│   │       └── typeorm-exception.filter.ts   # 全局异常过滤器
│   ├── config/
│   │   └── database.config.ts                # 数据源配置(registerAs)
│   ├── database/
│   │   ├── database.module.ts                # 全局数据库模块
│   │   ├── typeorm-logger.ts                 # 自定义 TypeORM 日志
│   │   └── migrations/                       # 迁移文件(CLI 生成)
│   │       └── 1700000000000-Init.ts
│   ├── modules/
│   │   └── user/
│   │       ├── entities/
│   │       │   └── user.entity.ts
│   │       ├── user.module.ts
│   │       ├── user.service.ts
│   │       └── user.controller.ts
│   ├── app.module.ts
│   └── main.ts
├── data-source.ts                            # CLI 专用数据源(迁移用)
├── .env
└── tsconfig.json

data-source.ts 为什么需要单独存在:TypeORM CLI(typeorm migration:generate 等命令)在运行时无法访问 NestJS 的 DI 容器,它只能读取一个导出 DataSource 实例的独立文件。因此需要将数据源配置提取为一个可独立运行的文件,与 NestJS 应用共享同一份配置逻辑,但不依赖任何 NestJS 模块。


二、安装与初始化

2.1 安装依赖

# NestJS TypeORM 集成包 + TypeORM 核心
pnpm add @nestjs/typeorm typeorm

# MySQL 驱动(选其一)
pnpm add mysql2

# PostgreSQL 驱动(选其一)
pnpm add pg
pnpm add -D @types/pg

2.2 配置 tsconfig.json

TypeORM 的 Entity 装饰器(@Entity@Column 等)依赖 TypeScript 的装饰器元数据功能,必须在 tsconfig.json 中开启以下两个选项:

// tsconfig.json
{
    "compilerOptions": {
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true
    }
}

三、与 ConfigService 整合(DataSource 配置)

3.1 创建数据库配置文件

.env 文件:

# .env
DB_TYPE=mysql
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=secret
DB_DATABASE=mydb
DB_SSL=false
DB_POOL_SIZE=10

3.2 创建数据库模块

使用 TypeOrmModule.forRootAsync 异步读取配置,避免在模块初始化时同步访问尚未加载的环境变量:

// src/database/database.module.ts
import { Global, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmLogger } from "./typeorm-logger";
import databaseConfig from "../config/database.config";

@Global()
@Module({
    imports: [
        TypeOrmModule.forRootAsync({
            imports: [ConfigModule],
            inject: [ConfigService],
            useFactory: (config: ConfigService) => {
                const db = config.get("database");
                const isProduction = config.get("NODE_ENV") === "production";

                return {
                    type: db.type as "mysql" | "postgres",
                    host: db.host,
                    port: db.port,
                    username: db.username,
                    password: db.password,
                    database: db.database,
                    ssl: db.ssl,
                    extra: db.extra,

                    // 自动加载通过 TypeOrmModule.forFeature() 注册的 Entity
                    autoLoadEntities: true,

                    // 生产环境必须关闭:会直接修改数据库结构,导致数据丢失
                    synchronize: !isProduction,

                    // 迁移文件位置
                    migrations: [__dirname + "/migrations/*{.ts,.js}"],

                    // 生产级日志配置:使用自定义 Logger 对接 NestJS Logger
                    logger: new TypeOrmLogger(isProduction),
                    logging: isProduction ? ["error", "warn"] : ["error", "warn", "query", "schema"],

                    // 慢查询告警阈值(毫秒)
                    maxQueryExecutionTime: 2000,
                };
            },
        }),
    ],
})
export class DatabaseModule {}

重要配置项说明

配置项说明
synchronize开发环境可开启(自动同步 schema),生产必须关闭,否则 Entity 变更会直接修改数据库结构,可能导致数据丢失
autoLoadEntities配合 forFeature() 自动注册 Entity,无需手动维护 entities 数组
logging生产建议仅开启 ['error', 'warn'],避免大量 SQL 日志影响性能和泄露敏感数据
maxQueryExecutionTime超过此阈值的查询会触发 logQuerySlow,用于慢查询告警

3.3 在 AppModule 中注册

确保 ConfigModuleDatabaseModule 都在 AppModule 中加载:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import databaseConfig from "./config/database.config";
import { DatabaseModule } from "./database/database.module";

@Module({
    imports: [
        ConfigModule.forRoot({
            isGlobal: true,
            load: [databaseConfig],
        }),
        DatabaseModule,
        // ...业务模块
    ],
})
export class AppModule {}

四、与日志系统整合

TypeORM 提供了 Logger 接口,允许我们将其内部日志输出对接到 NestJS 的 Logger,统一日志格式并支持慢查询告警:

// src/database/typeorm-logger.ts
import { Logger as NestLogger } from "@nestjs/common";
import { Logger, QueryRunner } from "typeorm";

export class TypeOrmLogger implements Logger {
    private readonly logger = new NestLogger("TypeORM");

    constructor(private readonly isProduction: boolean) {}

    // 记录 SQL 查询
    logQuery(query: string, parameters?: unknown[], _queryRunner?: QueryRunner) {
        if (!this.isProduction) {
            this.logger.debug(
                `Query: ${query}${parameters?.length ? ` -- Parameters: ${JSON.stringify(parameters)}` : ""}`,
            );
        }
    }

    // 记录查询错误
    logQueryError(
        error: string | Error,
        query: string,
        parameters?: unknown[],
        _queryRunner?: QueryRunner,
    ) {
        this.logger.error(
            `Query Failed: ${query}${parameters?.length ? ` -- Parameters: ${JSON.stringify(parameters)}` : ""}`,
            typeof error === "string" ? error : error.stack,
        );
    }

    // 慢查询告警
    logQuerySlow(time: number, query: string, parameters?: unknown[], _queryRunner?: QueryRunner) {
        this.logger.warn(
            `Slow Query (${time}ms): ${query}${parameters?.length ? ` -- Parameters: ${JSON.stringify(parameters)}` : ""}`,
        );
    }

    // schema 构建日志(开发环境)
    logSchemaBuild(message: string, _queryRunner?: QueryRunner) {
        if (!this.isProduction) {
            this.logger.log(`Schema: ${message}`);
        }
    }

    // 迁移日志
    logMigration(message: string, _queryRunner?: QueryRunner) {
        this.logger.log(`Migration: ${message}`);
    }

    // 普通日志
    log(level: "log" | "info" | "warn", message: unknown, _queryRunner?: QueryRunner) {
        switch (level) {
            case "warn":
                this.logger.warn(String(message));
                break;
            case "info":
                if (!this.isProduction) this.logger.verbose(String(message));
                break;
            default:
                if (!this.isProduction) this.logger.log(String(message));
        }
    }
}

这样所有 TypeORM 内部日志都会通过 NestJS Logger 输出,格式统一,慢查询会以 [WARN] 级别打印,方便监控系统采集。


五、定义 Entity

5.1 公共基类 BaseEntity

将所有 Entity 通用的字段(主键、时间戳、软删除)抽取到基类中,避免重复:

// src/common/entities/base.entity.ts
import {
    PrimaryGeneratedColumn,
    CreateDateColumn,
    UpdateDateColumn,
    DeleteDateColumn,
} from "typeorm";

export abstract class BaseEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @CreateDateColumn({ comment: "创建时间" })
    createdAt: Date;

    @UpdateDateColumn({ comment: "更新时间" })
    updatedAt: Date;

    // 软删除列:有值则表示已删除
    @DeleteDateColumn({ comment: "删除时间", nullable: true, select: false })
    deletedAt: Date | null;
}
  • @CreateDateColumn:insert 时自动填充当前时间
  • @UpdateDateColumn:每次 save/update 时自动更新为当前时间
  • @DeleteDateColumn:配合 softDelete() 使用,TypeORM 会自动在查询中过滤已软删除的记录

5.2 业务 Entity 示例

// src/modules/user/entities/user.entity.ts
import { Entity, Column, Index, BeforeInsert, BeforeUpdate, OneToMany } from "typeorm";
import * as bcrypt from "bcrypt";
import { BaseEntity } from "../../../common/entities/base.entity";
import { Post } from "../../post/entities/post.entity";

export enum UserRole {
    USER = "user",
    ADMIN = "admin",
}

@Entity("users")
@Index(["email"]) // 单列索引
@Index(["createdAt", "role"]) // 复合索引
export class User extends BaseEntity {
    @Column({ length: 100, comment: "用户名" })
    name: string;

    @Column({ unique: true, length: 200, comment: "邮箱" })
    email: string;

    // select: false 防止查询时自动返回密码
    @Column({ select: false, comment: "密码哈希" })
    password: string;

    @Column({
        type: "enum",
        enum: UserRole,
        default: UserRole.USER,
        comment: "角色",
    })
    role: UserRole;

    @Column({ nullable: true, length: 500, comment: "头像 URL" })
    avatar: string | null;

    @OneToMany(() => Post, (post) => post.author)
    posts: Post[];

    // 保存前自动哈希密码
    @BeforeInsert()
    @BeforeUpdate()
    async hashPassword() {
        // 仅当 password 字段被修改时才重新哈希
        if (this.password) {
            this.password = await bcrypt.hash(this.password, 12);
        }
    }
}

5.3 常用列类型速查

TypeORM 类型MySQL 对应PostgreSQL 对应说明
varchar(n)VARCHAR(n)VARCHAR(n)字符串(有长度限制)
textTEXTTEXT长文本
intINTINTEGER整数
bigintBIGINTBIGINT大整数(JS 中为 string)
decimal(p,s)DECIMAL(p,s)NUMERIC(p,s)精确小数
floatFLOATREAL浮点数
booleanTINYINT(1)BOOLEAN布尔值
jsonJSONJSONBJSON 数据
enumENUM自定义类型枚举
timestampDATETIMETIMESTAMP时间戳
dateDATEDATE日期

5.4 索引最佳实践

// 唯一索引
@Column({ unique: true })
email: string;

// 单列显式索引
@Index()
@Column()
phone: string;

// 复合索引(类级别)
@Index(['lastName', 'firstName'])
@Entity('users')
export class User {}

// 唯一复合索引
@Index(['tenantId', 'email'], { unique: true })
@Entity('users')
export class User {}

六、数据库迁移(Migration)

6.1 为什么生产环境必须禁用 synchronize

synchronize: true 会在每次应用启动时,对比 Entity 定义与数据库 schema 的差异并自动执行 DDL。这在生产环境极其危险:

  • 数据丢失:删除字段、修改列类型时会直接 DROP 列或 TRUNCATE 表
  • 不可预期:无法预先审查 SQL 变更
  • 无法回滚:出问题后没有回滚路径

正确做法:开发环境只在初始阶段使用 synchronize: true 快速成型,随后切换为迁移工作流;生产环境始终使用迁移。

6.2 创建 CLI 专用数据源文件

TypeORM CLI 无法访问 NestJS DI 容器,需要一个独立的 data-source.ts 文件:

// data-source.ts(项目根目录)
import "reflect-metadata";
import { DataSource } from "typeorm";
import * as dotenv from "dotenv";

// 手动加载 .env(NestJS 不可用时)
dotenv.config();

export const AppDataSource = new DataSource({
    type: (process.env.DB_TYPE as "mysql" | "postgres") || "mysql",
    host: process.env.DB_HOST || "localhost",
    port: parseInt(process.env.DB_PORT ?? "3306", 10),
    username: process.env.DB_USERNAME || "root",
    password: process.env.DB_PASSWORD || "",
    database: process.env.DB_DATABASE || "mydb",

    // 明确指定 Entity 和迁移文件的路径
    entities: ["src/**/*.entity{.ts,.js}"],
    migrations: ["src/database/migrations/*{.ts,.js}"],
    migrationsTableName: "migrations",

    // CLI 场景下不自动同步
    synchronize: false,
});
注意:data-source.tsDatabaseModule 使用相同的环境变量,但不依赖 NestJS。这是唯一需要维护"双份"配置的地方,但代价是可接受的。

6.3 配置迁移相关 scripts

package.json 中添加常用迁移命令,并安装 dotenvts-node

pnpm add -D ts-node dotenv
// package.json
{
    "scripts": {
        "migration:generate": "typeorm-ts-node-commonjs migration:generate -d data-source.ts",
        "migration:create": "typeorm-ts-node-commonjs migration:create",
        "migration:run": "typeorm-ts-node-commonjs migration:run -d data-source.ts",
        "migration:revert": "typeorm-ts-node-commonjs migration:revert -d data-source.ts",
        "migration:show": "typeorm-ts-node-commonjs migration:show -d data-source.ts"
    }
}

6.4 迁移 CLI 命令一览

生成迁移文件

# 对比 Entity 与数据库差异,自动生成迁移 SQL
pnpm migration:generate src/database/migrations/AddUserAvatar

生成文件示例 src/database/migrations/1700000000001-AddUserAvatar.ts

import { MigrationInterface, QueryRunner } from "typeorm";

export class AddUserAvatar1700000000001 implements MigrationInterface {
    name = "AddUserAvatar1700000000001";

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(
            `ALTER TABLE \`users\` ADD \`avatar\` varchar(500) NULL COMMENT '头像 URL'`,
        );
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query(`ALTER TABLE \`users\` DROP COLUMN \`avatar\``);
    }
}
  • up:执行迁移(向前)
  • down:回滚迁移(向后),必须完整实现,方便生产事故回滚

创建空迁移文件

# 手动编写 SQL 时使用
pnpm migration:create src/database/migrations/SeedInitialData

执行迁移

# 执行所有未运行的迁移
pnpm migration:run

回滚最近一次迁移

pnpm migration:revert

查看迁移状态

pnpm migration:show

输出示例:

[X] AddUserAvatar1700000000001
[ ] AddPostTable1700000000002   # 未执行

七、Repository 模式与 CRUD 实战

7.1 在 Feature 模块中注册 Entity

// src/modules/user/user.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { User } from "./entities/user.entity";
import { UserService } from "./user.service";
import { UserController } from "./user.controller";

@Module({
    imports: [TypeOrmModule.forFeature([User])],
    providers: [UserService],
    controllers: [UserController],
    exports: [UserService],
})
export class UserModule {}

TypeOrmModule.forFeature([User]) 会:

  1. User 注册到 DatabaseModuleautoLoadEntities
  2. 在当前模块的 DI 容器中提供 Repository<User>

7.2 注入并使用 Repository

// src/modules/user/user.service.ts
import { Injectable, NotFoundException, ConflictException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, FindManyOptions } from "typeorm";
import { User } from "./entities/user.entity";

@Injectable()
export class UserService {
    constructor(
        @InjectRepository(User)
        private readonly userRepo: Repository<User>,
    ) {}

    // 创建
    async create(data: Partial<User>): Promise<User> {
        const user = this.userRepo.create(data);
        return this.userRepo.save(user);
    }

    // 查询列表(带分页)
    async findAll(page = 1, limit = 20): Promise<[User[], number]> {
        return this.userRepo.findAndCount({
            skip: (page - 1) * limit,
            take: limit,
            order: { createdAt: "DESC" },
        });
    }

    // 查询单条
    async findOne(id: number): Promise<User> {
        const user = await this.userRepo.findOne({ where: { id } });
        if (!user) throw new NotFoundException(`用户 #${id} 不存在`);
        return user;
    }

    // 查询(包含密码字段,用于登录验证)
    async findOneWithPassword(email: string): Promise<User | null> {
        return this.userRepo
            .createQueryBuilder("user")
            .addSelect("user.password") // password 字段 select:false,需显式 addSelect
            .where("user.email = :email", { email })
            .getOne();
    }

    // 更新
    async update(id: number, data: Partial<User>): Promise<User> {
        const user = await this.findOne(id);
        Object.assign(user, data);
        return this.userRepo.save(user);
    }

    // 软删除
    async remove(id: number): Promise<void> {
        await this.findOne(id); // 确认存在
        await this.userRepo.softDelete(id);
    }

    // 硬删除
    async hardRemove(id: number): Promise<void> {
        await this.userRepo.delete(id);
    }

    // 恢复软删除
    async restore(id: number): Promise<void> {
        await this.userRepo.restore(id);
    }
}

7.3 常用 Repository API

方法说明
create(data)创建 Entity 实例(不写数据库)
save(entity)插入或更新(有 id 则更新,无则插入)
find(options)查询多条记录
findAndCount(options)查询多条记录 + 总数(分页用)
findOne(options)查询单条,不存在返回 null
findOneOrFail(options)查询单条,不存在抛出 EntityNotFoundError
update(criteria, partialEntity)部分更新(不触发 Entity 钩子)
delete(criteria)硬删除
softDelete(criteria)软删除(需有 @DeleteDateColumn
restore(criteria)恢复软删除
count(options)统计数量
exists(options)判断是否存在
save vs update 的区别save 会触发 @BeforeInsert/@BeforeUpdate 钩子(如密码哈希),update 直接执行 SQL UPDATE,不触发钩子。修改密码等需要触发钩子的场景应使用 save

7.4 QueryBuilder 进阶

对于复杂查询,QueryBuilderfind 选项更灵活:

// 复杂查询:多条件 + 联表 + 分页 + 排序
async findUsersWithPosts(
  keyword: string,
  role: UserRole,
  page: number,
  limit: number,
) {
  const qb = this.userRepo
    .createQueryBuilder('user')
    .leftJoinAndSelect('user.posts', 'post', 'post.published = :published', {
      published: true,
    })
    .where('user.role = :role', { role });

  // 动态条件拼接
  if (keyword) {
    qb.andWhere('(user.name LIKE :kw OR user.email LIKE :kw)', {
      kw: `%${keyword}%`,
    });
  }

  return qb
    .orderBy('user.createdAt', 'DESC')
    .skip((page - 1) * limit)
    .take(limit)
    .getManyAndCount();
}

// 子查询
async findActiveUsers() {
  return this.userRepo
    .createQueryBuilder('user')
    .where((qb) => {
      const subQuery = qb
        .subQuery()
        .select('post.authorId')
        .from(Post, 'post')
        .where('post.createdAt > :date', {
          date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
        })
        .getQuery();
      return `user.id IN ${subQuery}`;
    })
    .getMany();
}

7.5 自定义 Repository

对于复杂业务查询,可以继承 Repository<T> 封装:

// src/modules/user/user.repository.ts
import { Repository, DataSource } from "typeorm";
import { Injectable } from "@nestjs/common";
import { User, UserRole } from "./entities/user.entity";

@Injectable()
export class UserRepository extends Repository<User> {
    constructor(private dataSource: DataSource) {
        super(User, dataSource.createEntityManager());
    }

    async findAdminUsers(): Promise<User[]> {
        return this.createQueryBuilder("user")
            .where("user.role = :role", { role: UserRole.ADMIN })
            .andWhere("user.deletedAt IS NULL")
            .orderBy("user.createdAt", "DESC")
            .getMany();
    }

    async countByRole(): Promise<{ role: string; count: string }[]> {
        return this.createQueryBuilder("user")
            .select("user.role", "role")
            .addSelect("COUNT(*)", "count")
            .groupBy("user.role")
            .getRawMany();
    }
}

在模块中注册:

// user.module.ts
@Module({
    imports: [TypeOrmModule.forFeature([User])],
    providers: [UserService, UserRepository],
    controllers: [UserController],
})
export class UserModule {}

八、关联关系(Relations)

8.1 OneToOne — 一对一

// profile.entity.ts
@Entity('profiles')
export class Profile extends BaseEntity {
  @Column({ nullable: true })
  bio: string;

  // 外键存在于 profiles 表(@JoinColumn 所在方)
  @OneToOne(() => User, (user) => user.profile, { onDelete: 'CASCADE' })
  @JoinColumn()
  user: User;
}

// user.entity.ts
@OneToOne(() => Profile, (profile) => profile.user)
profile: Profile;
  • @JoinColumn 必须且只能在关系的拥有方(外键所在表)声明
  • onDelete: 'CASCADE' 表示删除 User 时自动删除关联的 Profile

8.2 OneToMany / ManyToOne — 一对多

// post.entity.ts
@Entity('posts')
export class Post extends BaseEntity {
  @Column()
  title: string;

  // 外键 authorId 在 posts 表
  @ManyToOne(() => User, (user) => user.posts, { onDelete: 'SET NULL', nullable: true })
  @JoinColumn({ name: 'author_id' })
  author: User;

  @Column({ nullable: true })
  authorId: number;
}

// user.entity.ts
@OneToMany(() => Post, (post) => post.author)
posts: Post[];
  • ManyToOne 不需要 @JoinColumn(默认添加),但可用 @JoinColumn({ name: 'author_id' }) 自定义列名
  • @OneToMany 没有对应数据库列,只是关系的反向引用

8.3 ManyToMany — 多对多

// tag.entity.ts
@Entity('tags')
export class Tag extends BaseEntity {
  @Column({ unique: true })
  name: string;

  @ManyToMany(() => Post, (post) => post.tags)
  posts: Post[];
}

// post.entity.ts
// @JoinTable 在关系的拥有方声明,TypeORM 会自动创建中间表 post_tags_tag
@ManyToMany(() => Tag, (tag) => tag.posts, { cascade: true })
@JoinTable({
  name: 'post_tags',           // 自定义中间表名
  joinColumn: { name: 'post_id' },
  inverseJoinColumn: { name: 'tag_id' },
})
tags: Tag[];

8.4 加载关联数据

方式一:relations 选项(简单场景)

// 查询时附带关联数据
const user = await this.userRepo.findOne({
    where: { id },
    relations: { posts: true, profile: true },
});

方式二:QueryBuilder leftJoinAndSelect(复杂场景)

const user = await this.userRepo
    .createQueryBuilder("user")
    .leftJoinAndSelect("user.posts", "post")
    .leftJoinAndSelect("post.tags", "tag")
    .where("user.id = :id", { id })
    .getOne();

方式二优势:可以在 JOIN 时添加额外条件、选择特定列,避免 N+1 查询。

关于懒加载(lazy: true:TypeORM 支持将关联声明为 Promise<T> 类型实现懒加载,但在 NestJS 中不推荐使用,原因是懒加载在异步上下文中容易引发连接泄漏,且行为难以预测。始终优先使用 eager 加载(relations 或 QueryBuilder)

九、事务(Transaction)

9.1 方式一:DataSource.transaction(推荐)

适合大多数业务场景,写法简洁,出现异常时自动回滚:

// src/modules/order/order.service.ts
import { Injectable } from "@nestjs/common";
import { DataSource } from "typeorm";

@Injectable()
export class OrderService {
    constructor(private readonly dataSource: DataSource) {}

    async createOrder(userId: number, items: OrderItem[]) {
        return this.dataSource.transaction(async (manager) => {
            // 在事务中,使用 manager 而非注入的 Repository
            const order = manager.create(Order, { userId });
            await manager.save(order);

            for (const item of items) {
                // 检查并扣减库存
                const product = await manager.findOneOrFail(Product, {
                    where: { id: item.productId },
                    lock: { mode: "pessimistic_write" }, // 悲观锁防止超卖
                });

                if (product.stock < item.quantity) {
                    throw new Error(`商品 ${product.name} 库存不足`);
                    // 抛出异常 → 事务自动回滚
                }

                product.stock -= item.quantity;
                await manager.save(product);

                const orderItem = manager.create(OrderItem, {
                    orderId: order.id,
                    productId: item.productId,
                    quantity: item.quantity,
                    price: product.price,
                });
                await manager.save(orderItem);
            }

            return order;
        });
    }
}

9.2 方式二:QueryRunner(细粒度控制)

适合需要分阶段控制事务、或需要在事务中执行原始 SQL 的复杂流程:

async transferBalance(fromId: number, toId: number, amount: number) {
  const queryRunner = this.dataSource.createQueryRunner();

  await queryRunner.connect();
  await queryRunner.startTransaction();

  try {
    const from = await queryRunner.manager.findOneOrFail(Account, {
      where: { id: fromId },
      lock: { mode: 'pessimistic_write' },
    });

    if (from.balance < amount) {
      throw new Error('余额不足');
    }

    await queryRunner.manager.decrement(Account, { id: fromId }, 'balance', amount);
    await queryRunner.manager.increment(Account, { id: toId }, 'balance', amount);

    // 记录流水
    await queryRunner.manager.save(Transaction, {
      fromId,
      toId,
      amount,
      type: 'transfer',
    });

    await queryRunner.commitTransaction();
  } catch (err) {
    await queryRunner.rollbackTransaction();
    throw err; // 重新抛出,由上层处理
  } finally {
    // 必须释放,否则连接泄漏
    await queryRunner.release();
  }
}

注意finally 中的 release() 是必须的,无论事务成功还是失败都必须执行,否则连接不会归还连接池,最终导致连接耗尽。


十、错误处理

10.1 TypeORM 常见错误类型

错误类型触发场景处理建议
QueryFailedError数据库执行 SQL 失败(约束冲突、语法错误等)检查 driverError.code,映射为业务错误
EntityNotFoundErrorfindOneOrFail / findOneByOrFail 未找到记录映射为 404
TypeORMErrorTypeORM 内部错误基类记录日志,返回 500
CannotCreateEntityIdMapError主键未定义(通常是代码 bug)检查 Entity 定义

10.2 QueryFailedError 数据库原生错误码

QueryFailedErrordriverError 属性包含数据库驱动抛出的原始错误,通过 code 字段区分具体类型:

MySQL 常用错误码

错误码说明示例场景
ER_DUP_ENTRY唯一约束冲突插入重复 email
ER_NO_REFERENCED_ROW_2外键约束失败(引用的记录不存在)引用不存在的 userId
ER_ROW_IS_REFERENCED_2外键约束失败(被其他表引用)删除有关联数据的记录
ER_DATA_TOO_LONG数据超过字段长度字符串过长
ER_BAD_NULL_ERROR非空字段插入 NULL必填字段缺失

PostgreSQL 常用错误码

错误码说明
23505唯一约束冲突(等同于 ER_DUP_ENTRY
23503外键约束失败
23502NOT NULL 约束失败
22001字符串过长

10.3 生产级全局异常过滤器

// src/common/filters/typeorm-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from "@nestjs/common";
import { Response } from "express";
import { QueryFailedError, EntityNotFoundError, TypeORMError } from "typeorm";

type DatabaseError = QueryFailedError & { driverError: { code: string; sqlMessage?: string } };

@Catch(QueryFailedError, EntityNotFoundError, TypeORMError)
export class TypeOrmExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(TypeOrmExceptionFilter.name);

    catch(exception: QueryFailedError | EntityNotFoundError | TypeORMError, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();

        if (exception instanceof EntityNotFoundError) {
            return response.status(HttpStatus.NOT_FOUND).json({
                statusCode: HttpStatus.NOT_FOUND,
                error: "Not Found",
                message: "请求的记录不存在",
            });
        }

        if (exception instanceof QueryFailedError) {
            return this.handleQueryFailed(exception as DatabaseError, response);
        }

        // 其他 TypeORM 内部错误
        this.logger.error(`TypeORM Error: ${exception.message}`, exception.stack);
        return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "数据库操作失败",
        });
    }

    private handleQueryFailed(exception: DatabaseError, response: Response) {
        this.logger.warn(`QueryFailedError: ${exception.message}`);

        const code = exception.driverError?.code;

        // MySQL
        switch (code) {
            case "ER_DUP_ENTRY":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "数据已存在,请勿重复提交",
                    code,
                });

            case "ER_NO_REFERENCED_ROW_2":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "关联的记录不存在",
                    code,
                });

            case "ER_ROW_IS_REFERENCED_2":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "存在关联数据,无法删除",
                    code,
                });

            case "ER_DATA_TOO_LONG":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "输入数据超过字段长度限制",
                    code,
                });

            // PostgreSQL
            case "23505":
                return response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "数据已存在,请勿重复提交",
                    code,
                });

            case "23503":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "关联的记录不存在",
                    code,
                });

            case "23502":
                return response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "必填字段不能为空",
                    code,
                });

            default:
                this.logger.error(`Unhandled DB error code: ${code}`, exception.message);
                return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
                    statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
                    error: "Internal Server Error",
                    message: "数据库操作失败",
                });
        }
    }
}

10.4 注册全局过滤器

方式一:main.ts(简洁,无依赖注入)

// src/main.ts
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { TypeOrmExceptionFilter } from "./common/filters/typeorm-exception.filter";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);
    app.useGlobalFilters(new TypeOrmExceptionFilter());
    await app.listen(3000);
}
bootstrap();

方式二:AppModule(支持依赖注入)

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { TypeOrmExceptionFilter } from "./common/filters/typeorm-exception.filter";

@Module({
    providers: [
        {
            provide: APP_FILTER,
            useClass: TypeOrmExceptionFilter,
        },
    ],
})
export class AppModule {}

10.5 在 Service 中主动处理特定错误

全局过滤器处理通用情况,Service 层可以针对业务场景进行更精细的控制:

async createUser(data: CreateUserDto) {
  try {
    const user = this.userRepo.create(data);
    return await this.userRepo.save(user);
  } catch (e) {
    if (e instanceof QueryFailedError) {
      const code = (e as any).driverError?.code;
      if (code === 'ER_DUP_ENTRY' || code === '23505') {
        throw new ConflictException('该邮箱已被注册,请更换邮箱');
      }
    }
    throw e; // 其他错误交由全局过滤器处理
  }
}

十一、总结

通过本文,我们完成了在 NestJS 中集成 TypeORM 的生产级完整流程:

环节关键点
项目结构data-source.ts 独立存在,供 CLI 使用;DatabaseModule 负责 NestJS 集成
tsconfigexperimentalDecorators + emitDecoratorMetadata 是 Entity 装饰器的必要前提
DataSource 配置forRootAsync + ConfigService 异步读取,autoLoadEntities 自动注册 Entity
生产配置synchronize: falselogging: ['error', 'warn']maxQueryExecutionTime 慢查询告警
日志整合实现 TypeORM Logger 接口,对接 NestJS Logger,按环境分级输出
BaseEntity集中管理 id、时间戳、软删除,避免重复代码
迁移生产环境只用 migration:run,开发用 migration:generate + migration:run
Repository@InjectRepository(Entity) 注入,save 触发钩子,update 不触发
事务简单场景用 DataSource.transaction,复杂流程用 QueryRunnerfinally 必须 release()
错误处理QueryFailedError 处理数据库约束错误,EntityNotFoundError 映射 404,全局过滤器统一收口

TypeORM vs Prisma 选型建议

  • 选 TypeORM:团队熟悉 JPA/Hibernate/EF 风格;需要复杂 QueryBuilder;项目已有大量 TypeORM Entity;偏好装饰器驱动的代码组织
  • 选 Prisma:重视类型安全和自动补全;Schema-first 开发流程;对 SQL 细节不要求完全控制;新项目绿地开发

两者在 NestJS 生态中都有成熟的支持,根据团队偏好和项目需求选择即可。

[/hide]

12 NestJS 集成 Prisma ORM 完全指南(Prisma v7)

作者 木灵鱼儿
2026年8月16日 04:51

前言

Prisma 是 Node.js 和 TypeScript 生态中最受欢迎的 ORM 之一。相比 TypeORM,它提供了更强的类型安全性、更直观的 Schema 语法,以及自动生成的迁移文件。Prisma v7 已全面转向 ES Module,而 NestJS 默认使用 CommonJS,因此需要做一些特殊处理——本文会详细说明。

本文基于 Prisma v7NestJS 最新版,从安装到生产级集成,覆盖以下内容:

  • 正确安装配置 Prisma,与 ConfigService 和日志系统整合
  • 使用 prisma.config.ts 统一管理 schema、迁移、seed 与数据源配置
  • 生产级项目目录结构设计
  • 定义数据模型,生成和执行迁移
  • 常用 CLI 命令详解(包括迁移解决和回滚)
  • Prisma 完整错误类型与处理策略
  • 生产级全局异常过滤器实现
本文假设你已有一个使用 @nestjs/config 的 NestJS 项目。如需了解 ConfigModule 的配置,请参考本系列第 01 篇。

[hide]

一、生产级项目目录结构

在集成 Prisma 之前,先规划一个符合业界最佳实践的目录结构:

project-root/
├── prisma/
│   ├── schema.prisma           # 数据模型定义
│   ├── seed.ts                 # 数据库种子文件
│   └── migrations/             # 迁移文件目录(自动生成)
│       └── 20240101000000_init/
│           └── migration.sql
├── prisma.config.ts            # Prisma CLI 配置(v7 新增)
├── src/
│   ├── common/                 # 通用模块
│   │   ├── filters/            # 全局过滤器
│   │   │   └── prisma-exception.filter.ts
│   │   ├── interceptors/       # 全局拦截器
│   │   └── guards/             # 全局守卫
│   ├── config/                 # 配置相关
│   │   └── database.config.ts  # 数据库配置
│   ├── database/               # 数据库基础设施层
│   │   ├── prisma.service.ts   # Prisma 服务
│   │   ├── prisma.module.ts    # Prisma 模块
│   │   └── utils/
│   │       └── prisma-error.util.ts  # Prisma 错误判断工具
│   ├── modules/                # 业务模块
│   │   ├── user/
│   │   │   ├── user.module.ts
│   │   │   ├── user.service.ts
│   │   │   ├── user.controller.ts
│   │   │   └── dto/
│   │   └── post/
│   ├── generated/              # Prisma 生成的客户端(.gitignore)
│   │   └── prisma/
│   ├── app.module.ts
│   └── main.ts
├── .env                        # 环境变量
├── .env.example                # 环境变量示例
├── .gitignore
├── package.json
└── tsconfig.json

核心设计原则

  1. 关注点分离:数据库基础设施(database/)与业务逻辑(modules/)分离
  2. 可维护性:通用功能(过滤器、拦截器)集中在 common/ 目录
  3. 安全性:生成的代码(generated/)和环境变量(.env)不提交到版本控制
  4. 可测试性:清晰的模块划分便于单元测试和集成测试

二、安装与初始化配置

2.1 安装依赖

首先安装 Prisma CLI(开发依赖)和运行时所需的包:

# Prisma CLI(仅开发阶段使用)
pnpm add prisma --save-dev

# Prisma 客户端 + PostgreSQL 驱动适配器
pnpm add @prisma/client @prisma/adapter-pg pg

# Prisma Config 加载 .env,以及执行 TypeScript seed 脚本
pnpm add dotenv
pnpm add -D tsx

pnpm add -D @types/pg

数据库驱动说明:Prisma v7 采用驱动适配器(Driver Adapter)架构,不同数据库需安装对应包:

数据库驱动适配器包
PostgreSQL@prisma/adapter-pg + pg
MySQL@prisma/adapter-mysql2 + mysql2
SQLite@prisma/adapter-better-sqlite3 + better-sqlite3
SQL Server@prisma/adapter-mssql + mssql

2.2 初始化 Prisma

在项目根目录运行以下命令,将 Prisma 客户端的生成路径指定到 src 目录内:

npx prisma init --output ../src/generated/prisma

命令执行后会生成以下文件:

prisma/
└── schema.prisma      # 数据库 schema 定义
prisma.config.ts       # Prisma 项目配置(v7 新增,默认在项目根目录)
.env                   # 数据库连接字符串
src/
└── generated/
    └── prisma/        # 生成的 Prisma 客户端(勿手动修改)

将生成的客户端目录加入 .gitignore

# .gitignore
src/generated/

2.3 配置 schema.prisma

打开 prisma/schema.prisma,按以下方式配置生成器和数据源:

// prisma/schema.prisma

generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  // 关键:Prisma v7 默认生成 ESM,NestJS 使用 CommonJS,必须指定 cjs
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
为什么需要 moduleFormat = "cjs":Prisma v7 默认以 ESM 格式输出客户端代码,而 NestJS 项目默认是 CommonJS 模块系统,两者不兼容。显式设置 moduleFormat = "cjs" 即可解决。

三、配置 prisma.config.ts

Prisma v7 新增了 prisma.config.ts,用于统一管理 Prisma CLI 的项目级配置。prisma init 会默认在项目根目录生成该文件,CLI 命令会自动读取它,因此 schema 路径、迁移目录、seed 命令和数据源地址都应集中写在这里。

在项目根目录创建或修改 prisma.config.ts

// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
    schema: "prisma/schema.prisma",
    migrations: {
        path: "prisma/migrations",
        seed: "tsx prisma/seed.ts",
    },
    datasource: {
        url: env("DATABASE_URL"),
    },
});

配置项说明:

配置项说明
schemaPrisma schema 文件路径,也可以指向包含多个 .prisma 文件的目录
migrations.path迁移文件目录,Prisma Migrate 会在这里读取和生成迁移
migrations.seed执行 prisma db seed 时运行的命令,v7 推荐写在这里
datasource.url数据库连接地址,通常通过 env("DATABASE_URL") 从环境变量读取
datasource.shadowDatabaseUrl可选,Prisma Migrate 使用的 shadow database 地址,云数据库场景常见
typedSql.path可选,TypedSQL SQL 文件目录
views.path可选,数据库视图 SQL 定义目录

几个需要注意的细节:

  1. 环境变量需要显式加载:Node.js 项目推荐在文件顶部写 import "dotenv/config";,否则 Prisma CLI 加载配置时不一定能读取 .env对于环境变量的用法,建议参考《01 NestJS 环境变量与配置管理(Config 模块)》文章的处理方式,这里只是简单使用
  2. 路径相对配置文件解析schemamigrations.path 等相对路径都以 prisma.config.ts 所在目录为基准,而不是以执行命令时的当前目录为基准。
  3. env() 会强校验变量存在env("DATABASE_URL") 在变量缺失时会直接报错。如果 CI 中只运行 prisma generate 且没有数据库地址,可以改用 process.env.DATABASE_URL ?? ""
  4. seed 不再写入 package.json:旧版本常见的 package.jsonprisma.seed 写法,在 v7 中应迁移到 migrations.seed

如果项目较大,也可以使用官方支持的 .config/prisma.ts 作为配置文件位置;小型或常规 NestJS 项目直接使用根目录 prisma.config.ts 最清晰。


四、与 ConfigService 整合

4.1 环境变量配置

.env 文件中设置数据库连接字符串:

# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"

确保 AppModule 中已注册 ConfigModule(参考本系列第 01 篇),这样 .env 才能被正确加载:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";

@Module({
    imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        // ...其他模块
    ],
})
export class AppModule {}

4.2 创建 PrismaService

src/database 目录下创建 prisma.service.ts,通过 ConfigService 读取数据库连接字符串:

// src/database/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "../generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
    private readonly logger = new Logger(PrismaService.name);

    constructor(private readonly configService: ConfigService) {
        const adapter = new PrismaPg({
            connectionString: configService.get<string>("DATABASE_URL"),
        });
        super({ adapter });
    }

    async onModuleInit(): Promise<void> {
        await this.$connect();
        this.logger.log("Database connection established");
    }

    async onModuleDestroy(): Promise<void> {
        await this.$disconnect();
        this.logger.log("Database connection closed");
    }
}

关于生命周期钩子的说明

  • OnModuleInit:NestJS 模块初始化完成后调用,此时依赖注入已完成,是建立数据库连接的最佳时机
  • OnModuleDestroy:应用关闭前调用,确保数据库连接被优雅地关闭,避免连接泄漏
  • 为什么需要显式连接:虽然 Prisma Client 支持延迟连接(首次查询时自动连接),但在 NestJS 中显式管理连接有以下优势:

    1. 启动时故障快速发现:如果数据库不可达,应用启动时立即报错,而不是等到第一次查询
    2. 优雅关闭:应用关闭时正确释放数据库连接,避免连接池耗尽
    3. 健康检查:便于实现应用健康检查端点(Health Check)

五、与日志系统整合

Prisma 支持将内部查询日志、警告等事件通过 NestJS Logger 输出,便于开发调试和生产监控。完善的日志配置应当:

  1. 开发环境:输出详细的 SQL 查询语句、参数和执行时间
  2. 生产环境:只记录警告和错误,避免敏感数据泄露和性能开销
  3. 结构化日志:使用 NestJS Logger 统一日志格式,便于集中式日志收集

修改 PrismaService,实现环境感知的日志配置:

// src/database/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaClient } from "../generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
    private readonly logger = new Logger(PrismaService.name);

    constructor(private readonly configService: ConfigService) {
        const adapter = new PrismaPg({
            connectionString: configService.get<string>("DATABASE_URL"),
        });

        const isProduction = configService.get<string>("NODE_ENV") === "production";

        super({
            adapter,
            // 根据环境配置日志级别
            log: isProduction
                ? [
                        // 生产环境:只记录警告和错误
                        { emit: "event", level: "warn" },
                        { emit: "event", level: "error" },
                    ]
                : [
                        // 开发环境:记录查询、警告和错误
                        { emit: "event", level: "query" },
                        { emit: "event", level: "warn" },
                        { emit: "event", level: "error" },
                        { emit: "stdout", level: "info" },
                    ],
        });
    }

    async onModuleInit(): Promise<void> {
        // 注册日志事件监听器
        const isProduction = process.env.NODE_ENV === "production";

        // 开发环境下记录 SQL 查询详情
        if (!isProduction) {
            this.$on("query" as never, (e: any) => {
                this.logger.debug(`Query: ${e.query}`);
                this.logger.debug(`Params: ${e.params}`);
                this.logger.debug(`Duration: ${e.duration}ms`);
            });
        }

        // 所有环境都记录警告和错误
        this.$on("warn" as never, (e: any) => {
            this.logger.warn(e.message);
        });

        this.$on("error" as never, (e: any) => {
            this.logger.error(e.message);
        });

        // 建立数据库连接
        await this.$connect();
        this.logger.log("Database connection established");
    }

    async onModuleDestroy(): Promise<void> {
        await this.$disconnect();
        this.logger.log("Database connection closed");
    }
}

生产环境日志最佳实践

日志级别开发环境生产环境原因
querySQL 可能包含敏感数据(如用户输入),且影响性能
info一般信息,可选
warn需要关注的潜在问题
error必须记录的错误信息

日志输出示例(开发环境):

[PrismaService] Database connection established
[PrismaService] Query: SELECT "User"."id", "User"."email" FROM "User" WHERE "User"."id" = $1 LIMIT $2
[PrismaService] Params: [1, 1]
[PrismaService] Duration: 12ms

六、定义数据模型与数据库迁移

6.1 数据模型定义

prisma/schema.prisma 中添加数据模型(model 块):

// prisma/schema.prisma

generator client {
  provider     = "prisma-client"
  output       = "../src/generated/prisma"
  moduleFormat = "cjs"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String?
  role      Role      @default(USER)
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
  posts     Post[]
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
}

enum Role {
  USER
  ADMIN
}

6.2 常用字段修饰符速览

修饰符说明示例
@id主键id Int @id
@default(...)默认值@default(autoincrement()) / @default(now()) / @default(uuid())
@unique唯一约束email String @unique
@updatedAt更新时自动填充当前时间updatedAt DateTime @updatedAt
@relation定义外键关联@relation(fields: [authorId], references: [id])
@map映射到数据库列名createdAt DateTime @map("created_at")
@@map映射到数据库表名@@map("users")
@@index添加索引@@index([email, name])
?可空字段name String?

6.3 常用 CLI 命令完全指南

prisma migrate dev — 开发环境迁移(最常用)

npx prisma migrate dev --name <迁移名称>

功能

  • 将 schema 变更转换为 SQL 迁移文件
  • 自动执行迁移
  • 自动运行 prisma generate 更新客户端类型
  • 仅用于开发环境,会重置开发数据库

常见用法

# 初始化迁移
npx prisma migrate dev --name init

# 添加新字段后执行
npx prisma migrate dev --name add-user-avatar

# 创建迁移但不执行(预览 SQL)
npx prisma migrate dev --create-only --name add-index

prisma migrate deploy — 生产环境迁移

npx prisma migrate deploy

功能

  • 执行所有待执行的迁移(不生成新迁移,不重置数据)
  • 用于 CI/CD 和生产环境,安全且不具破坏性
  • 只能向前迁移,不能回滚

使用场景

  • 生产环境部署
  • CI/CD 流水线中的自动化部署
  • 预生产环境(Staging)数据库更新
# 典型 CI/CD 用法
npx prisma migrate deploy
npx prisma generate

prisma migrate resolve — 解决迁移问题

npx prisma migrate resolve --applied <迁移名称>
npx prisma migrate resolve --rolled-back <迁移名称>

功能

  • 手动标记迁移状态,用于修复迁移历史记录
  • 不实际执行或回滚 SQL,只更新 _prisma_migrations 表中的记录

使用场景

  1. 标记已应用的迁移(当迁移已手动执行但未记录):
npx prisma migrate resolve --applied "20240101000000_init"
  1. 标记已回滚的迁移(当迁移失败需要重新应用):
npx prisma migrate resolve --rolled-back "20240101000000_failed_migration"

典型故障恢复流程

# 1. 假设迁移失败,先手动修复数据库
# 2. 标记失败的迁移为已回滚
npx prisma migrate resolve --rolled-back "20240101000000_failed_migration"

# 3. 修复迁移 SQL 文件
# 4. 重新执行迁移
npx prisma migrate deploy

prisma migrate diff — 对比 schema 差异

npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-schema-datasource prisma/schema.prisma \
  --script

功能

  • 对比两个数据源(schema 文件、数据库、迁移目录)之间的差异
  • 输出 SQL 脚本或 JSON 格式的差异

常见用法

# 查看 schema 与数据库的差异(生成 SQL 脚本)
npx prisma migrate diff \
  --from-schema-datasource prisma/schema.prisma \
  --to-url "postgresql://user:pass@localhost:5432/mydb" \
  --script

# 对比两个数据库
npx prisma migrate diff \
  --from-url "postgresql://user:pass@localhost:5432/dev" \
  --to-url "postgresql://user:pass@localhost:5432/prod" \
  --script

# 输出 JSON 格式的差异
npx prisma migrate diff \
  --from-schema-datamodel prisma/schema.prisma \
  --to-schema-datasource prisma/schema.prisma

prisma migrate status — 查看迁移状态

npx prisma migrate status

功能

  • 显示所有迁移的执行状态
  • 检测是否有待执行的迁移
  • 检测 schema 与数据库是否同步

输出示例

Database schema is up to date!

Migrations:
  ✓ 20240101000000_init
  ✓ 20240102000000_add_user_avatar
  ✗ 20240103000000_add_posts (pending)

prisma db push — 快速原型开发

npx prisma db push

功能

  • 直接将 schema 同步到数据库,不生成迁移文件
  • 适合快速原型开发或实验性 schema 变更
  • 不推荐在有生产数据的库上使用

对比 migrate dev

特性db pushmigrate dev
生成迁移文件
版本控制
数据保留尽力保留,不保证保证(通过迁移 SQL)
使用场景原型开发、实验正式开发、团队协作

prisma generate — 生成/更新客户端

npx prisma generate

功能

  • 根据 schema 生成 TypeScript 类型和查询客户端
  • 修改 schema 后必须运行此命令

自动触发场景

  • 运行 prisma migrate dev 时自动执行
  • 不会prisma migrate deploy 时自动执行(需手动运行)

prisma studio — 可视化数据库管理

npx prisma studio

功能

  • 在浏览器中打开图形化界面(默认 http://localhost:5555)
  • 可查看和编辑数据库数据
  • 仅用于开发调试

prisma migrate reset — 重置开发数据库

npx prisma migrate reset

功能

  • 删除数据库,重新执行所有迁移(危险操作,会清空所有数据
  • 自动运行 seed 脚本(如果配置了)
  • 仅用于开发环境

使用场景

  • 开发环境迁移历史混乱,需要重新开始
  • 测试数据污染,需要恢复到初始状态

prisma db seed — 填充种子数据

Prisma v7 的 seed 命令配置写在 prisma.config.tsmigrations.seed 中,不再写 package.json

// prisma.config.ts
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
    schema: "prisma/schema.prisma",
    migrations: {
        path: "prisma/migrations",
        seed: "tsx prisma/seed.ts",
    },
    datasource: {
        url: env("DATABASE_URL"),
    },
});
npx prisma db seed

执行 prisma migrate reset 时,如果已配置 migrations.seed,Prisma 也会在重置后自动运行 seed 脚本。

种子文件示例

// prisma/seed.ts
import { PrismaClient } from "../src/generated/prisma/client";

const prisma = new PrismaClient();

async function main() {
    // 创建测试用户
    await prisma.user.upsert({
        where: { email: "admin@example.com" },
        update: {},
        create: {
            email: "admin@example.com",
            name: "Admin User",
            role: "ADMIN",
        },
    });

    console.log("Seed data created successfully");
}

main()
    .catch((e) => {
        console.error(e);
        process.exit(1);
    })
    .finally(async () => {
        await prisma.$disconnect();
    });

prisma validate — 验证 schema 语法

npx prisma validate

功能

  • 检查 schema 文件的语法错误
  • 验证模型关系、字段类型等是否正确
  • 不连接数据库,纯静态检查

使用场景

  • CI/CD 中的代码质量检查
  • 提交前验证 schema 修改

prisma format — 格式化 schema 文件

npx prisma format

功能

  • 自动格式化 schema 文件(对齐、排序)
  • 确保团队代码风格一致

prisma db pull — 从数据库反向生成 schema

npx prisma db pull

功能

  • 内省(introspection)现有数据库,生成 Prisma schema
  • 用于将现有数据库迁移到 Prisma

使用场景

  • 将遗留项目迁移到 Prisma
  • 从其他 ORM 迁移到 Prisma

6.4 CLI 命令速查表

命令用途环境修改数据库生成迁移文件
migrate dev开发环境迁移开发
migrate deploy生产环境迁移生产/CI
migrate resolve手动修复迁移状态所有
migrate diff对比差异所有
migrate status查看迁移状态所有
migrate reset重置数据库开发✅(危险)
db push快速同步 schema开发
db pull从数据库生成 schema所有
db seed填充种子数据开发
generate生成客户端所有
studio打开可视化界面开发
validate验证 schema 语法所有
format格式化 schema所有

七、注册 PrismaService

创建 PrismaModule 并将其设为全局模块,避免在每个功能模块中重复导入:

// src/database/prisma.module.ts
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";

@Global()
@Module({
    providers: [PrismaService],
    exports: [PrismaService],
})
export class PrismaModule {}

AppModule 中注册:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./database/prisma.module";

@Module({
    imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        PrismaModule,
        // ...其他业务模块
    ],
})
export class AppModule {}

八、Prisma 错误类型与处理

8.1 Prisma 错误类型完整概览

Prisma 提供了五种主要错误类型,每种都有特定的触发场景和处理策略:

1. PrismaClientKnownRequestError

触发场景:数据库返回已知的、可预测的错误(如约束冲突)

属性

  • code:Prisma 特定错误码(如 P2002
  • meta:错误的额外信息(如冲突的字段名)
  • message:错误描述
  • clientVersion:Prisma Client 版本

常见错误码

错误码含义示例场景
P2002唯一约束冲突尝试插入重复的 email
P2003外键约束失败引用的关联记录不存在
P2025记录不存在更新或删除不存在的记录
P2014关系约束冲突删除有关联数据的记录
P2000字段值过长输入的字符串超过字段长度限制
P2011非空约束冲突必填字段为 null
P2024连接池超时并发连接数超过限制

2. PrismaClientUnknownRequestError

触发场景:数据库返回未知的、无错误码的错误

属性

  • message:错误描述
  • clientVersion:Prisma Client 版本

处理策略:记录完整错误信息,作为内部服务器错误处理


3. PrismaClientRustPanicError

触发场景:Prisma 查询引擎(Rust 编写)崩溃

属性

  • message:崩溃信息
  • clientVersion:Prisma Client 版本

处理策略

  • 这是严重的引擎错误,需要重启整个 Node.js 进程
  • 记录完整堆栈信息并上报监控系统
  • 在 NestJS 中,可以通过进程管理器(如 PM2)自动重启

4. PrismaClientInitializationError

触发场景:数据库连接初始化失败

常见原因

  • 数据库连接字符串错误
  • 数据库服务器不可达
  • 网络问题
  • 环境变量缺失
  • 查询引擎二进制文件缺失

属性

  • errorCode:错误码(如 P1001P1002
  • message:错误描述
  • clientVersion:Prisma Client 版本

常见错误码

错误码含义
P1000数据库认证失败
P1001无法连接到数据库服务器
P1002数据库连接超时
P1003数据库不存在
P1008操作超时
P1017服务器关闭了连接

5. PrismaClientValidationError

触发场景:Prisma Client 查询参数验证失败(在发送到数据库之前)

常见原因

  • 必填字段缺失
  • 字段类型错误
  • 查询参数格式错误

属性

  • message:验证错误详情
  • clientVersion:Prisma Client 版本

示例

// 错误:缺少必填字段 email
await prisma.user.create({
    data: {
        name: "John",
        // email 字段缺失
    },
});
// 抛出 PrismaClientValidationError

8.2 错误码分类与完整列表

通用错误(P1xxx)- 连接和认证

错误码说明
P1000数据库认证失败
P1001无法连接到数据库服务器
P1002数据库连接超时
P1003数据库不存在
P1008操作超时
P1009数据库已存在
P1010用户访问被拒绝
P1011TLS 连接错误
P1012Schema 验证错误
P1013数据库连接字符串无效
P1014模型对应的表不存在
P1015数据库版本不支持当前特性
P1016原始查询参数数量不匹配
P1017服务器关闭了连接

查询引擎错误(P2xxx)- 数据操作

错误码说明
P2000字段值过长
P2001记录不存在
P2002唯一约束冲突
P2003外键约束失败
P2004数据库约束失败
P2005字段值类型无效
P2006提供的字段值无效
P2007数据验证错误
P2008查询解析失败
P2009查询验证失败
P2010原始查询失败
P2011非空约束冲突
P2012缺少必填值
P2013缺少必填参数
P2014关系约束冲突
P2015找不到关联记录
P2016查询解释错误
P2017关系记录未连接
P2018找不到必需的关联记录
P2019输入错误
P2020值超出范围
P2021表不存在
P2022列不存在
P2023列数据不一致
P2024连接池超时
P2025操作依赖的记录不存在
P2026数据库不支持该特性
P2027多个错误
P2028事务 API 错误
P2029查询参数超限
P2030找不到全文索引
P2031MongoDB 需要副本集
P2033数字超出 64 位整数范围
P2034事务冲突或死锁
P2035数据库断言失败
P2036外部连接器错误
P2037数据库连接过多

迁移引擎错误(P3xxx)- 迁移操作

错误码说明
P3000创建数据库失败
P3001迁移可能导致数据丢失
P3002迁移回滚
P3003迁移格式已更改
P3004系统数据库不应被更改
P3005数据库 schema 不为空
P3006迁移未能应用到影子数据库
P3008迁移已被标记为已应用
P3009发现失败的迁移
P3010迁移名称过长
P3011迁移无法回滚(未应用)
P3012迁移无法回滚(未失败)
P3014无法创建影子数据库
P3015找不到迁移文件
P3017找不到迁移
P3018迁移应用失败
P3019数据源提供者不匹配
P3020Azure SQL 禁用影子数据库

内省错误(P4xxx)- Schema 拉取

错误码说明
P4000内省操作失败
P4001数据库为空
P4002数据库 schema 不一致

8.3 创建 Prisma 错误判断工具

由于 Prisma 没有提供统一的错误基类,我们需要创建一个工具函数来判断是否为 Prisma 错误:

// src/database/utils/prisma-error.util.ts
import { Prisma } from "../../generated/prisma/client";

/**
 * 判断是否为 Prisma 错误
 */
export function isPrismaError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
    return (
        error instanceof Prisma.PrismaClientKnownRequestError ||
        error instanceof Prisma.PrismaClientUnknownRequestError ||
        error instanceof Prisma.PrismaClientRustPanicError ||
        error instanceof Prisma.PrismaClientInitializationError ||
        error instanceof Prisma.PrismaClientValidationError
    );
}

/**
 * 判断是否为特定错误码的 Prisma 错误
 */
export function isPrismaErrorWithCode(
    error: unknown,
    code: string,
): error is Prisma.PrismaClientKnownRequestError {
    return error instanceof Prisma.PrismaClientKnownRequestError && error.code === code;
}

/**
 * 获取 Prisma 错误的友好消息
 */
export function getPrismaErrorMessage(error: unknown): string {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
        return `数据库操作失败: ${error.code} - ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientValidationError) {
        return `数据验证失败: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientInitializationError) {
        return `数据库连接失败: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientRustPanicError) {
        return `数据库引擎崩溃: ${error.message}`;
    }
    if (error instanceof Prisma.PrismaClientUnknownRequestError) {
        return `未知数据库错误: ${error.message}`;
    }
    return "未知错误";
}

/**
 * 提取唯一约束冲突的字段名
 */
export function extractUniqueConstraintFields(
    error: Prisma.PrismaClientKnownRequestError,
): string[] {
    if (error.code === "P2002" && error.meta?.target) {
        return Array.isArray(error.meta.target) ? error.meta.target : [error.meta.target as string];
    }
    return [];
}

8.4 生产级全局异常过滤器

创建一个完善的全局过滤器,处理所有 Prisma 错误类型:

// src/common/filters/prisma-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from "@nestjs/common";
import { Response } from "express";
import { Prisma } from "../../generated/prisma/client";
import {
    isPrismaError,
    getPrismaErrorMessage,
    extractUniqueConstraintFields,
} from "../../database/utils/prisma-error.util";

/**
 * Prisma 全局异常过滤器
 * 捕获所有 Prisma 相关错误并转换为标准 HTTP 响应
 */
@Catch()
export class PrismaExceptionFilter implements ExceptionFilter {
    private readonly logger = new Logger(PrismaExceptionFilter.name);

    catch(exception: unknown, host: ArgumentsHost) {
        const ctx = host.switchToHttp();
        const response = ctx.getResponse<Response>();

        // 仅处理 Prisma 错误,其他错误继续抛出
        if (!isPrismaError(exception)) {
            throw exception;
        }

        // 处理已知的数据库请求错误
        if (exception instanceof Prisma.PrismaClientKnownRequestError) {
            this.handleKnownRequestError(exception, response);
            return;
        }

        // 处理验证错误
        if (exception instanceof Prisma.PrismaClientValidationError) {
            this.handleValidationError(exception, response);
            return;
        }

        // 处理初始化错误
        if (exception instanceof Prisma.PrismaClientInitializationError) {
            this.handleInitializationError(exception, response);
            return;
        }

        // 处理引擎崩溃
        if (exception instanceof Prisma.PrismaClientRustPanicError) {
            this.handleRustPanicError(exception, response);
            return;
        }

        // 处理未知请求错误
        if (exception instanceof Prisma.PrismaClientUnknownRequestError) {
            this.handleUnknownRequestError(exception, response);
            return;
        }
    }

    /**
     * 处理已知的数据库请求错误
     */
    private handleKnownRequestError(
        exception: Prisma.PrismaClientKnownRequestError,
        response: Response,
    ) {
        this.logger.warn(`Prisma Known Error [${exception.code}]: ${exception.message}`);

        const { code, meta } = exception;

        switch (code) {
            case "P2002": {
                // 唯一约束冲突
                const fields = extractUniqueConstraintFields(exception);
                const fieldNames = fields.length > 0 ? fields.join(", ") : "字段";
                response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: `${fieldNames} 已存在,请使用其他值`,
                    code,
                });
                break;
            }

            case "P2025": {
                // 记录不存在
                response.status(HttpStatus.NOT_FOUND).json({
                    statusCode: HttpStatus.NOT_FOUND,
                    error: "Not Found",
                    message: "请求的记录不存在",
                    code,
                });
                break;
            }

            case "P2003": {
                // 外键约束失败
                const field = meta?.field_name || "关联字段";
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `关联的记录不存在,外键约束失败: ${field}`,
                    code,
                });
                break;
            }

            case "P2014": {
                // 关系约束冲突
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: "无法删除或修改,存在关联数据",
                    code,
                });
                break;
            }

            case "P2000": {
                // 字段值过长
                const column = meta?.column_name || "字段";
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `${column} 的值过长,超过字段长度限制`,
                    code,
                });
                break;
            }

            case "P2011": {
                // 非空约束冲突
                const constraint = meta?.constraint || "必填字段";
                response.status(HttpStatus.BAD_REQUEST).json({
                    statusCode: HttpStatus.BAD_REQUEST,
                    error: "Bad Request",
                    message: `${constraint} 不能为空`,
                    code,
                });
                break;
            }

            case "P2024": {
                // 连接池超时
                response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
                    statusCode: HttpStatus.SERVICE_UNAVAILABLE,
                    error: "Service Unavailable",
                    message: "数据库连接池超时,请稍后重试",
                    code,
                });
                break;
            }

            case "P2034": {
                // 事务冲突或死锁
                response.status(HttpStatus.CONFLICT).json({
                    statusCode: HttpStatus.CONFLICT,
                    error: "Conflict",
                    message: "事务冲突,请重试",
                    code,
                });
                break;
            }

            default: {
                // 其他未明确处理的错误
                this.logger.error(`Unhandled Prisma error code: ${code}`);
                response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
                    statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
                    error: "Internal Server Error",
                    message: "数据库操作失败",
                    code,
                });
            }
        }
    }

    /**
     * 处理参数验证错误
     */
    private handleValidationError(exception: Prisma.PrismaClientValidationError, response: Response) {
        this.logger.warn(`Prisma Validation Error: ${exception.message}`);

        response.status(HttpStatus.BAD_REQUEST).json({
            statusCode: HttpStatus.BAD_REQUEST,
            error: "Bad Request",
            message: "请求参数格式错误或缺少必填字段",
        });
    }

    /**
     * 处理初始化错误(数据库连接失败)
     */
    private handleInitializationError(
        exception: Prisma.PrismaClientInitializationError,
        response: Response,
    ) {
        this.logger.error(`Prisma Initialization Error: ${exception.message}`, exception.stack);

        response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
            statusCode: HttpStatus.SERVICE_UNAVAILABLE,
            error: "Service Unavailable",
            message: "数据库服务暂时不可用,请稍后重试",
        });
    }

    /**
     * 处理引擎崩溃(严重错误,需要重启)
     */
    private handleRustPanicError(exception: Prisma.PrismaClientRustPanicError, response: Response) {
        this.logger.fatal(`Prisma Rust Panic Error: ${exception.message}`, exception.stack);

        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "服务器内部错误,请联系管理员",
        });

        // 在生产环境中,这里应该触发进程重启
        // 如使用 PM2:process.exit(1)
    }

    /**
     * 处理未知请求错误
     */
    private handleUnknownRequestError(
        exception: Prisma.PrismaClientUnknownRequestError,
        response: Response,
    ) {
        this.logger.error(`Prisma Unknown Request Error: ${exception.message}`, exception.stack);

        response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
            statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
            error: "Internal Server Error",
            message: "数据库操作失败",
        });
    }
}

8.5 注册全局异常过滤器

有两种方式注册全局过滤器:

方式一:在 main.ts 中注册(推荐)

// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // 注册 Prisma 全局异常过滤器
    app.useGlobalFilters(new PrismaExceptionFilter());

    await app.listen(3000);
}
bootstrap();

优点:简单直接,适合不需要依赖注入的过滤器

缺点:过滤器无法使用依赖注入(如注入 ConfigService


方式二:在 AppModule 中注册(支持依赖注入)

// src/app.module.ts
import { Module } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "./database/prisma.module";
import { PrismaExceptionFilter } from "./common/filters/prisma-exception.filter";

@Module({
    imports: [
        ConfigModule.forRoot({ isGlobal: true }),
        PrismaModule,
        // ...其他模块
    ],
    providers: [
        // 注册全局过滤器(支持依赖注入)
        {
            provide: APP_FILTER,
            useClass: PrismaExceptionFilter,
        },
    ],
})
export class AppModule {}

优点:过滤器可以使用依赖注入,访问其他服务

缺点:稍微复杂一些

如果需要在过滤器中使用依赖注入,例如注入 ConfigService 来区分环境:

// src/common/filters/prisma-exception.filter.ts
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

@Injectable() // 添加 @Injectable 装饰器
@Catch()
export class PrismaExceptionFilter implements ExceptionFilter {
    constructor(private readonly configService: ConfigService) {}

    catch(exception: unknown, host: ArgumentsHost) {
        const isProduction = this.configService.get("NODE_ENV") === "production";

        // 在生产环境中隐藏详细错误信息
        if (isProduction) {
            // 返回简化的错误信息
        }
        // ...
    }
}

8.6 在 Service 中主动处理特定错误

对于需要业务定制的场景,也可以在 Service 层直接捕获:

// src/modules/user/user.service.ts
import { Injectable, ConflictException, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../database/prisma.service";
import { Prisma } from "../../generated/prisma/client";
import { isPrismaErrorWithCode } from "../../database/utils/prisma-error.util";

@Injectable()
export class UserService {
    constructor(private readonly prisma: PrismaService) {}

    async create(data: Prisma.UserCreateInput) {
        try {
            return await this.prisma.user.create({ data });
        } catch (e) {
            // 处理特定的唯一约束冲突
            if (isPrismaErrorWithCode(e, "P2002")) {
                throw new ConflictException("该邮箱已被注册");
            }
            throw e; // 其余错误交由全局过滤器处理
        }
    }

    async findOneOrFail(id: number) {
        const user = await this.prisma.user.findUnique({ where: { id } });
        if (!user) {
            throw new NotFoundException(`用户 #${id} 不存在`);
        }
        return user;
    }

    async remove(id: number) {
        try {
            return await this.prisma.user.delete({ where: { id } });
        } catch (e) {
            // P2025: 记录不存在
            if (isPrismaErrorWithCode(e, "P2025")) {
                throw new NotFoundException(`用户 #${id} 不存在`);
            }
            // P2014: 有关联数据,无法删除
            if (isPrismaErrorWithCode(e, "P2014")) {
                throw new ConflictException("该用户有关联数据,无法删除");
            }
            throw e;
        }
    }
}

九、总结

通过本文,我们完成了在 NestJS 中集成 Prisma v7 的生产级完整流程:

环节关键点
项目结构数据库基础设施(database/)与业务逻辑(modules/)分离
安装配置moduleFormat = "cjs" 解决 ESM/CJS 兼容问题
ConfigService 整合在 constructor 参数中直接使用 config.get(),无需等待 super()
生命周期管理OnModuleInit 显式建立连接,OnModuleDestroy 优雅关闭,支持启动时故障检测
日志整合环境感知的日志配置:开发环境详细日志,生产环境仅警告和错误
数据库迁移开发用 migrate dev,生产用 migrate deploy,故障用 migrate resolve
CLI 命令完整的迁移、回滚、对比、验证等命令,支持复杂的生产环境需求
全局模块@Global() + PrismaModule 避免重复导入
错误处理五种错误类型全覆盖,工具函数辅助判断,全局过滤器统一处理
过滤器注册支持 main.ts 简单注册和 AppModule 依赖注入两种方式

[/hide]

11 NestJS 注册与登录接口的密码安全设计

作者 木灵鱼儿
2026年8月16日 01:34

前言

在上一篇《10 NestJS JWT 身份验证完全指南》中,我们完整实现了基于 JWT 的身份验证体系,其中已涉及 Argon2id 哈希和时序攻击防护。但密码安全不止于此 —— 注册和登录接口是攻击者最频繁的目标,一旦设计失误,后果往往是大规模账号泄露。

本文聚焦于密码在整个生命周期(注册 → 存储 → 验证 → 重置)中的威胁模型和防护实践,所有代码示例均为生产级实现,可直接集成到已有项目中。

阅读本文需要完成《10 NestJS JWT 身份验证完全指南》的实践,或具备等同的 NestJS 认证体系基础。


[hide]

第一部分:威胁模型

在写代码前,先明确我们要对抗的攻击类型。不理解攻击原理,就无法评估防御是否有效。

1.1 彩虹表攻击(Rainbow Table Attack)

攻击原理:

攻击者预先计算大量明文密码的哈希值,建成一张"哈希 → 明文"的查找表(即彩虹表)。一旦获取数据库中的哈希值,只需查表就能反查出原始密码,无需逆向哈希算法本身。

攻击流程:
1. 攻击者拖库,获得 user 表中的 password_hash 字段
2. 对比彩虹表:e10adc3949ba59abbe56e057f20f883e → 123456
3. 无需暴力破解,直接得到明文密码

为什么彩虹表能奏效?

因为哈希是确定性的:同一个输入永远产生同一个输出。MD5("123456") 永远是 e10adc3...,全球所有使用 MD5 存储 123456 的系统哈希值都一样。彩虹表一次构建,处处可用。

防御:加盐(Salt)

盐是一段随机字符串,在哈希前拼接到密码上:

hash("123456")              → e10adc3...(可查彩虹表)
hash("123456" + "x7k9mQ2p") → a3f8c1...(彩虹表中没有这条记录)

每个用户的盐都不同,即便两个用户的密码相同,哈希值也不同,彩虹表完全失效。

Argon2id 自动处理盐: 调用 hash() 时,库会在内部生成随机盐并嵌入哈希结果字符串中,验证时自动提取——你无需手动管理盐。

解释:

  1. 通过加盐的方式,可以让相同的密码在不同用户之间产生不同的哈希值,从而防止彩虹表攻击(1:1对照成为不可能)。
  2. 即便攻击者获取了数据库中的哈希值,从哈希中解析出盐 (盐(Salt)从来就不是用来保密的,它的公开完全在设计预期之内)和密码的哈希值,也无法直接通过彩虹表反查出原始密码,它需要加上盐生成一份新的彩虹表,而每个用户的盐都不同(不设置固定Salt属性值情况下),攻击者无法为每个用户单独生成彩虹表,成本极高。
  3. 方式2上就已经不能称之为彩虹表攻击了,因为彩虹表是预先计算好的,而重新针对性生成一般都是暴力破解的方式。

1.2 字典攻击(Dictionary Attack)

攻击者不穷举所有组合,而是使用包含数百万常用密码的字典(RockYou、SecLists 等),逐一尝试。弱密码(123456passwordqwerty)在秒级内被破解。

防御: 密码强度策略 + 禁止使用已知弱密码(HIBP API)。

1.3 凭证填充攻击(Credential Stuffing)

其他网站泄露的"用户名 + 密码"组合,被自动化工具批量在你的系统上尝试登录。由于大量用户在多个网站使用相同密码,成功率远高于暴力破解。

2024 年已公开泄露:RockYou2024,含 100 亿条明文密码记录
这些记录被直接用于凭证填充

防御: 登录限流 + IP 封锁 + 异常行为检测 + 强制注册时使用未泄露密码(HIBP)。

1.4 暴力破解(Brute Force)

穷举所有可能的密码组合。现代 GPU 每秒可计算数十亿次 MD5,对于 8 位纯数字密码(10^8 = 1 亿种组合)只需数秒。

防御: 使用计算成本高的哈希算法(Argon2id)+ 账号锁定 + 登录限流。

1.5 时序攻击(Timing Attack)

通过精确测量操作响应时间,推断内部逻辑分支。例如:

  • 用户不存在:服务器 2ms 返回"用户名或密码错误"
  • 用户存在但密码错误:服务器 350ms 返回"用户名或密码错误"

攻击者遍历用户名,响应时间明显更长的表示该用户名已注册——这叫用户枚举(User Enumeration)

防御: 无论用户是否存在,都执行完整的 verify 操作,响应时间趋于一致。


第二部分:密码存储安全

2.1 哈希算法选型

先看清楚现状:

算法状态原因
MD5禁止使用非密码学安全哈希,GPU 每秒数十亿次,彩虹表完备
SHA-1/2禁止使用同上,设计用于速度,密码哈希恰恰需要"慢"
bcrypt可用老牌算法,自带盐,但内存需求低,GPU 并行破解较易
scrypt可用内存硬化,强于 bcrypt,但参数调优复杂
Argon2id推荐使用2015 密码哈希竞赛冠军,内存+CPU 双硬化,OWASP 2024 首选

为什么"慢"是优点: 你的服务器每次登录花 200ms 验证密码,用户感知不到差异;攻击者用 GPU 每秒尝试 10 亿次密码,却要等每次 200ms——直接把破解时间从数秒拉到数十年。

2.2 Argon2id 生产参数

OWASP Authentication Cheat Sheet(2024)推荐的最低参数:

// src/auth/utils/password.util.ts
import { hash, verify, Algorithm } from "@node-rs/argon2";

/**
 * OWASP 推荐参数(2024):
 * - memoryCost: 19456 (19 MiB) — 内存硬化,限制 GPU 并行
 * - timeCost: 2              — 迭代次数,增加 CPU 开销
 * - parallelism: 1           — 并行度,单核场景保持 1
 *
 * 生产环境建议在压测后适当提高 memoryCost(64 MiB 更佳),
 * 以系统单次 hash 耗时 300~500ms 为基准调整。
 */
const ARGON2_OPTIONS = {
    algorithm: Algorithm.Argon2id,
    memoryCost: 19456,
    timeCost: 2,
    parallelism: 1,
} as const;

export async function hashPassword(plain: string): Promise<string> {
    return hash(plain, ARGON2_OPTIONS);
}

export async function verifyPassword(hashed: string, plain: string): Promise<boolean> {
    return verify(hashed, plain);
}

2.3 渐进式参数升级

随着硬件性能提升,当前参数在几年后可能不够安全。生产系统需要支持在用户下次登录时静默升级哈希:

// src/auth/utils/password.util.ts(扩展)
import { needsRehash } from "@node-rs/argon2";

/**
 * 检测存储的哈希是否使用了旧参数,是则在验证通过后重新哈希。
 * needsRehash 通过解析哈希字符串中的参数段实现,无需明文密码。
 */
export function isHashOutdated(hashed: string): boolean {
    return needsRehash(hashed, ARGON2_OPTIONS);
}

AuthService.signIn 中,验证通过后检测并升级:

// src/auth/auth.service.ts(片段)
async signIn(username: string, plain: string): Promise<TokenPair> {
  const user = await this.usersService.findByUsername(username);

  // 防时序攻击:用户不存在时仍执行 verify,使响应时间趋于一致
  const passwordHash = user?.passwordHash ?? DUMMY_HASH;
  const isValid = await verifyPassword(passwordHash, plain);

  if (!user || !isValid) {
    throw new UnauthorizedException("用户名或密码错误");
  }

  // 渐进式哈希升级:参数过时则用最新参数重新哈希,用户无感知
  if (isHashOutdated(user.passwordHash)) {
    const newHash = await hashPassword(plain);
    await this.usersService.updatePasswordHash(user.id, newHash);
  }

  return this.issueTokens(user);
}

// 占位哈希:与真实哈希耗时相当,防止时序差异
// 用 hashPassword("dummy") 在启动时预计算,而非硬编码
const DUMMY_HASH =
  "$argon2id$v=19$m=19456,t=2,p=1$placeholder$placeholder";

第三部分:注册接口安全设计

3.1 密码强度校验

仅靠 MinLength(8) 远远不够,需要结合熵值和字符多样性:

// src/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, Matches, IsNotEmpty } from "class-validator";

export class RegisterDto {
    @IsEmail()
    email: string;

    @IsString()
    @IsNotEmpty()
    @MinLength(3)
    @MaxLength(32)
    // 用户名:字母开头,只含字母数字下划线,禁止纯数字
    @Matches(/^[a-zA-Z][a-zA-Z0-9_]{2,31}$/, {
        message: "用户名须以字母开头,只含字母、数字和下划线",
    })
    username: string;

    @IsString()
    @MinLength(12, { message: "密码至少 12 位" })
    @MaxLength(128, { message: "密码不能超过 128 位" })
    password: string;
}
MaxLength(128):bcrypt 存在 72 字节截断问题;Argon2 无此限制,但设置上限防止超长输入导致的 DoS(故意提交 10MB 密码拖垮服务器)。

密码强度推荐使用 zxcvbn 库进行基于模式的评估,它能识别键盘走位(qwerty123)、字典词、日期、重复字符等攻击者真正会利用的弱密码模式,比纯正则校验更接近实际破解能力:

pnpm add zxcvbn
pnpm add -D @types/zxcvbn
// src/auth/utils/password-strength.util.ts
import zxcvbn from "zxcvbn";

export interface PasswordStrengthResult {
    valid: boolean;
    score: number; // 0-4,对应 zxcvbn 评分等级
    feedback: string[];
}

/**
 * 使用 zxcvbn 评估密码强度。
 * 评分说明:0-1 太弱(拒绝),2 一般,3 较强,4 非常强。
 * 生产建议要求 score >= 3。
 */
export function checkPasswordStrength(password: string): PasswordStrengthResult {
    const result = zxcvbn(password);
    const feedback: string[] = [];

    if (result.feedback.warning) {
        feedback.push(result.feedback.warning);
    }
    feedback.push(...result.feedback.suggestions);

    return {
        valid: result.score >= 3,
        score: result.score,
        feedback,
    };
}

zxcvbn 返回的 feedback.warning 是具体问题描述(如"这是一个常用密码"),feedback.suggestions 是改进建议,可以直接回传给前端展示。

3.2 集成 Have I Been Pwned(HIBP)检测

HIBP 收录了超过 130 亿条已泄露密码。注册时检测用户密码是否出现在历史泄露中,强制拒绝已知弱密码。

HIBP 使用 k-Anonymity 模型:客户端只发送密码 SHA-1 哈希的前 5 位,服务端返回所有匹配前缀的哈希后缀列表,本地比对——服务端永远不会看到完整密码哈希。

// src/auth/utils/hibp.util.ts
import { createHash } from "node:crypto";

/**
 * 使用 HIBP k-Anonymity API 检测密码是否在已知泄露数据库中。
 * 仅发送 SHA-1 的前 5 字节(10 位十六进制),不泄露完整密码。
 *
 * @returns 泄露次数,0 表示未泄露
 */
export async function checkPasswordPwned(password: string): Promise<number> {
    const sha1 = createHash("sha1").update(password).digest("hex").toUpperCase();
    const prefix = sha1.slice(0, 5);
    const suffix = sha1.slice(5);

    const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
        headers: {
            // 减少 NTLM 哈希传输开销(默认),此处使用 SHA-1 模式
            "Add-Padding": "true", // 启用填充,防止流量分析
        },
        signal: AbortSignal.timeout(3000), // 3 秒超时,防止外部接口拖慢注册
    });

    if (!response.ok) {
        // HIBP 不可用时静默降级,不阻断注册流程(可配置为严格模式)
        return 0;
    }

    const text = await response.text();
    // 响应格式:SUFFIX:COUNT\n...
    const line = text.split("\n").find((l) => l.startsWith(suffix));
    if (!line) return 0;

    return parseInt(line.split(":")[1], 10);
}

在注册服务中使用:

// src/auth/auth.service.ts(注册方法)
async register(dto: RegisterDto): Promise<void> {
  // 1. 密码强度
  const strength = checkPasswordStrength(dto.password);
  if (!strength.valid) {
    throw new BadRequestException({
      message: "密码强度不足",
      feedback: strength.feedback,
    });
  }

  // 2. HIBP 泄露检测(失败降级,不阻断)
  const pwnedCount = await checkPasswordPwned(dto.password).catch(() => 0);
  if (pwnedCount > 0) {
    throw new BadRequestException(
      `该密码已出现在 ${pwnedCount.toLocaleString()} 次数据泄露中,请更换密码`,
    );
  }

  // 3. 用户名/邮箱唯一性检测(防用户枚举——见 3.3 节)
  const exists = await this.usersService.existsByEmailOrUsername(
    dto.email,
    dto.username,
  );
  if (exists) {
    // 不要区分"邮箱已注册"和"用户名已注册",防止枚举
    throw new ConflictException("该用户名或邮箱已被使用");
  }

  // 4. 哈希密码
  const passwordHash = await hashPassword(dto.password);

  // 5. 写库 + 发送邮箱验证邮件
  const user = await this.usersService.create({
    email: dto.email,
    username: dto.username,
    passwordHash,
    emailVerified: false,
  });

  await this.emailService.sendVerification(user.id, user.email);
}

3.3 防用户枚举(注册场景)

注册接口的用户枚举攻击:攻击者批量尝试邮箱注册,通过响应判断哪些邮箱已注册,进而发动精准的钓鱼或凭证填充攻击。

错误做法:

// ❌ 明确告知枚举信息
if (emailExists) throw new ConflictException("该邮箱已注册");
if (usernameExists) throw new ConflictException("该用户名已被占用");

正确做法:

// ✅ 统一返回,不区分具体冲突原因
if (exists) throw new ConflictException("该用户名或邮箱已被使用");

对于邮箱注册场景,更隐蔽的做法是"统一发邮件":

// 无论邮箱是否已注册,都返回 200 并声称"如果邮箱有效,将收到邮件"
// 若已注册:发送"有人尝试用你的邮箱注册,如果不是你请忽略"的通知邮件
// 若未注册:发送正常的验证邮件
// 攻击者无法通过响应区分两种情况
async register(dto: RegisterDto) {
  // ... 验证逻辑 ...

  const existingUser = await this.usersService.findByEmail(dto.email);
  if (existingUser) {
    // 已存在:发送安全提醒邮件,不泄露信息
    await this.emailService.sendAccountExistsAlert(dto.email);
    return; // 正常返回,不抛错
  }

  // 正常注册流程
  // ...
}

3.4 限流前置:反向代理与真实 IP 配置

本文所有限流均依赖 @nestjs/throttler 以真实客户端 IP 为单位计数。当应用部署在 Nginx 反向代理后面时,NestJS 直接读到的 req.ip 是 Nginx 与应用之间的内网地址(如 127.0.0.1),而非客户端真实 IP——所有用户在限流层都被归为同一来源,相当于限流形同虚设。

要让整条链路正确工作,Nginx 和 NestJS 两端都需要配置:

真实客户端 (IP: 1.2.3.4)
    │
    │  HTTP 请求
    ▼
Nginx(公网 443)
    │  注入请求头:
    │    X-Real-IP: 1.2.3.4
    │    X-Forwarded-For: 1.2.3.4
    │
    │  转发给内网
    ▼
NestJS(内网 127.0.0.1:3000)
    │  trust proxy = 1 告诉 Express:
    │  "读取 X-Forwarded-For 作为 req.ip"
    ▼
req.ip === "1.2.3.4"  ✅
ThrottlerGuard 按 "1.2.3.4" 计数  ✅

第一步:Nginx 注入客户端 IP 请求头

# nginx.conf(server 块内)
location /api/ {
    proxy_pass          http://127.0.0.1:3000;

    # 注入真实客户端 IP,供后端读取
    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    Host              $host;
}
  • $remote_addr:Nginx 直接收到的连接 IP,即真实客户端 IP(此处 Nginx 是第一层代理,没有更上层的代理了)。
  • $proxy_add_x_forwarded_for:在原有 X-Forwarded-For 后追加 $remote_addr,多层代理时形成完整 IP 链。

第二步:NestJS 开启 trust proxy

// src/main.ts
async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // 告诉 Express 信任距离应用最近的 1 层代理(即 Nginx)
    // 此后 req.ip 会从 X-Forwarded-For 中读取真实客户端 IP
    app.getHttpAdapter().getInstance().set("trust proxy", 1);

    await app.listen(3000);
}
bootstrap();
为什么是 1 而不是 true 设为 true 表示信任所有来源的代理声明,此时攻击者只需在请求头中手动添加 X-Forwarded-For: 任意IP 即可伪造来源,完全绕过限流。设为 1 只信任最后一跳(Nginx 追加的 IP),用户请求中伪造的 X-Forwarded-For 会被追加到链末尾而不会被当作 req.ip 读取,无法伪造。

完成以上两步后,后续所有 @Throttle 装饰器都能正确以真实客户端 IP 为维度计数。

3.5 注册限流

注册接口若不限流,会被用来批量创建垃圾账号或枚举用户名:

// src/auth/auth.controller.ts
import { Throttle } from "@nestjs/throttler";

@Public()
@Throttle({ default: { ttl: 3_600_000, limit: 5 } }) // 每 IP 每小时最多注册 5 次
@Post("register")
@HttpCode(HttpStatus.CREATED)
async register(@Body() dto: RegisterDto) {
  await this.authService.register(dto);
  return { message: "注册成功,请检查邮箱完成验证" };
}

第四部分:登录接口安全设计

4.1 完整的时序攻击防护

上一篇文章已有介绍,这里给出更完整的生产实现,包括占位哈希的预热:

// src/auth/auth.service.ts
import { OnModuleInit } from "@nestjs/common";

@Injectable()
export class AuthService implements OnModuleInit {
    private dummyHash!: string;

    /**
     * 模块初始化时预计算占位哈希。
     * 不能硬编码,因为 Argon2 会在哈希中嵌入随机盐,
     * 每次调用 hash() 的结果都不同,但 verify() 耗时稳定。
     */
    async onModuleInit() {
        this.dummyHash = await hashPassword("dummy-warmup-value");
    }

    /**
     * 验证用户名和密码,成功后签发 access + refresh token 对。
     * @param username 用户名(不是邮箱),用于查询用户记录
     * @param plain    用户提交的明文密码,仅在此方法内参与 verify,不会持久化或传播
     * @returns        包含 access_token、refresh_token 及过期信息的 TokenPair
     */
    async signIn(username: string, plain: string): Promise<TokenPair> {
        const user = await this.usersService.findByUsername(username);

        // 用户不存在时使用预计算的占位哈希,使耗时与真实验证趋于一致
        const hashToVerify = user?.passwordHash ?? this.dummyHash;
        const isValid = await verifyPassword(hashToVerify, plain);

        // 合并判断:不区分"用户不存在"和"密码错误",防止信息泄露
        if (!user || !isValid) {
            // 登录失败:记录审计日志
            this.logger.warn("登录失败", {
                username,
                reason: !user ? "user_not_found" : "wrong_password",
            });
            throw new UnauthorizedException("用户名或密码错误");
        }

        // 检查账号状态
        if (user.status === "banned") {
            throw new ForbiddenException("账号已被封禁");
        }
        if (!user.emailVerified) {
            throw new ForbiddenException("请先完成邮箱验证");
        }

        // 渐进式哈希升级
        if (isHashOutdated(user.passwordHash)) {
            const newHash = await hashPassword(plain);
            await this.usersService.updatePasswordHash(user.id, newHash);
        }

        this.logger.log("登录成功", { userId: user.id, username });
        return this.issueTokens(user);
    }
}

4.2 登录失败锁定机制

纯限流(throttler)是按 IP 限制,无法防止分布式攻击(多 IP 攻击单一账号)。账号维度的失败计数是必要补充:

// src/auth/auth.service.ts(账号锁定部分)

private readonly MAX_FAILED_ATTEMPTS = 5;
private readonly LOCKOUT_DURATION_SECONDS = 15 * 60; // 15 分钟

/**
 * 验证用户名和密码,成功后签发 access + refresh token 对。
 * @param username 用户名(不是邮箱),用于查询用户记录
 * @param plain    用户提交的明文密码,仅在此方法内参与 verify,不会持久化或传播
 * @returns        包含 access_token、refresh_token 及过期信息的 TokenPair
 */
async signIn(username: string, plain: string): Promise<TokenPair> {
  const user = await this.usersService.findByUsername(username);
  const hashToVerify = user?.passwordHash ?? this.dummyHash;

  // 检查账号锁定状态(在 verify 前,避免锁定账号仍消耗算力)
  if (user) {
    const lockKey = `auth:lockout:${user.id}`;
    const locked = await this.redis.get(lockKey);
    if (locked) {
      const ttl = await this.redis.ttl(lockKey);
      throw new TooManyRequestsException(
        `账号已临时锁定,请在 ${Math.ceil(ttl / 60)} 分钟后重试`,
      );
    }
  }

  const isValid = await verifyPassword(hashToVerify, plain);

  if (!user || !isValid) {
    if (user) {
      await this.recordFailedAttempt(user.id);
    }
    throw new UnauthorizedException("用户名或密码错误");
  }

  // 登录成功:清除失败计数
  if (user) {
    await this.redis.del(`auth:attempts:${user.id}`);
  }

  return this.issueTokens(user);
}

/** 累加账号登录失败次数,达到阈值后写入锁定 key 并通知用户 */
private async recordFailedAttempt(userId: number): Promise<void> {
  const attemptsKey = `auth:attempts:${userId}`;
  const attempts = await this.redis.incr(attemptsKey);

  // 首次计数时设置过期,防止永久累积
  if (attempts === 1) {
    await this.redis.expire(attemptsKey, 3600); // 1 小时重置
  }

  if (attempts >= this.MAX_FAILED_ATTEMPTS) {
    const lockKey = `auth:lockout:${userId}`;
    await this.redis.setex(lockKey, this.LOCKOUT_DURATION_SECONDS, "1");
    // 触发锁定时发送安全邮件通知用户
    await this.notifyAccountLocked(userId).catch(() => void 0);
    this.logger.warn("账号触发锁定", { userId, attempts });
  }
}
锁定策略权衡: 永久锁定(需人工解锁)防护最强,但攻击者可故意触发锁定来拒绝服务(DoS 合法用户)。临时锁定 + 指数退避是更合理的折中。

4.3 登录限流分层策略

@nestjs/throttlerreq.ip 为单位计数,确保 IP 正确传递需要先完成 3.4 节的 Nginx + trust proxy 配置。先在 AppModule 中注册命名的限流规则:

// src/app.module.ts
import { ThrottlerModule } from "@nestjs/throttler";

@Module({
    imports: [
        ThrottlerModule.forRoot([
            { name: "short", ttl: 60_000, limit: 100 }, // 全局默认:1 分钟 100 次
            { name: "long", ttl: 3_600_000, limit: 500 }, // 全局默认:1 小时 500 次
        ]),
        // ...
    ],
})
export class AppModule {}

在登录接口上通过 @Throttle 覆盖全局配置,单独收紧为 5/20 次:

// src/auth/auth.controller.ts
@Public()
@Throttle({
  short: { ttl: 60_000,    limit: 5  }, // 1 分钟最多 5 次,防止脚本高频尝试
  long:  { ttl: 3_600_000, limit: 20 }, // 1 小时最多 20 次,防止低速持续攻击
})
@HttpCode(HttpStatus.OK)
@Post("login")
signIn(@Body() dto: SignInDto) {
  return this.authService.signIn(dto.username, dto.password);
}

其他业务接口不加 @Throttle,沿用全局的宽松配置;只有登录这类高敏感接口需要收紧。


第五部分:密码重置安全设计

密码重置是最容易被忽视的攻击面。错误实现可导致任意账号接管。

5.1 完整流程概览

在写代码之前,先明确整条链路的用户操作路径与服务端对应动作:

sequenceDiagram
    actor 用户
    participant 前端
    participant NestJS
    participant DB as 数据库 (password_resets 表)
    participant 邮件服务

    Note over 用户,邮件服务: ── 申请重置 ──
    用户->>前端: 输入邮箱,点击"忘记密码"
    前端->>NestJS: POST /auth/forgot-password { email }
    NestJS->>DB: 查询用户是否存在
    alt 用户不存在
        NestJS-->>前端: 200(响应与存在时相同,防枚举)
    else 用户存在
        NestJS->>NestJS: 生成 rawToken(32字节随机)
        NestJS->>NestJS: SHA-256(rawToken) → tokenHash
        NestJS->>DB: UPSERT { userId, tokenHash, expiresAt, used:false }
        NestJS->>邮件服务: 发送含重置链接的邮件(链接携带 rawToken)
        NestJS-->>前端: 200
    end
    前端-->>用户: 提示"若邮箱存在,将收到重置邮件"

    Note over 用户,邮件服务: ── 执行重置 ──
    用户->>邮件服务: 打开邮件,点击重置链接
    邮件服务-->>前端: 跳转到重置密码页面(URL 携带 token 参数)
    用户->>前端: 输入新密码,提交
    前端->>NestJS: POST /auth/reset-password { token: rawToken, newPassword }
    NestJS->>NestJS: SHA-256(rawToken) → tokenHash
    NestJS->>DB: 按 tokenHash 查询记录
    alt 记录不存在 / 已使用 / 已过期
        NestJS-->>前端: 400 重置链接无效或已过期
    else 记录有效
        NestJS->>NestJS: 密码强度 + HIBP 检测
        NestJS->>NestJS: Argon2id(newPassword) → passwordHash
        NestJS->>DB: 事务:<br/>1. 标记 used=true<br/>2. 更新 password_hash<br/>3. 递增 tokenVersion(吊销旧 JWT)
        NestJS->>邮件服务: 发送"密码已修改"安全通知
        NestJS-->>前端: 200
    end
    前端-->>用户: 提示重置成功,跳转登录页

5.2 数据库表设计

password_resets 表仅存储令牌哈希,从不存储原始令牌

// src/auth/entities/password-reset.entity.ts
import {
    Column,
    CreateDateColumn,
    Entity,
    Index,
    ManyToOne,
    PrimaryGeneratedColumn,
} from "typeorm";
import { UserEntity } from "../../users/entities/user.entity";

@Entity("password_resets")
export class PasswordResetEntity {
    @PrimaryGeneratedColumn()
    id: number;

    @ManyToOne(() => UserEntity, { onDelete: "CASCADE" })
    user: UserEntity;

    @Column()
    userId: number;

    // 存 SHA-256 哈希:即便数据库泄露,攻击者拿到 tokenHash 也无法直接用——
    // 服务端验证时要求提供原始 rawToken,再 hash 后比对
    @Index({ unique: true })
    @Column({ length: 64 })
    tokenHash: string;

    @Column()
    expiresAt: Date;

    // 令牌消费后立即置 true,防止同一令牌被使用两次
    @Column({ default: false })
    used: boolean;

    @CreateDateColumn()
    createdAt: Date;
}

如果使用 Prisma,对应的 schema 如下:

// prisma/schema.prisma

model PasswordReset {
  id        Int      @id @default(autoincrement())
  userId    Int
  // 只存 SHA-256 哈希,不存原始 token
  tokenHash String   @unique @db.VarChar(64)
  expiresAt DateTime
  used      Boolean  @default(false)
  createdAt DateTime @default(now())

  // 每个用户同一时刻只允许存在一条重置记录,upsert 以此为冲突键
  @@unique([userId])
  @@map("password_resets")
}

字段说明:

字段内容为什么这样设计
tokenHashSHA-256(rawToken)不存原文,泄露后无法直接使用;以此字段查询记录
expiresAt当前时间 + 30 分钟限制令牌有效窗口,超时自动失效
used消费后设为 true一次性语义,防止重放攻击
唯一索引tokenHash 上建唯一索引按哈希查询效率高,同时保证数据库层不存在重复令牌

5.3 申请重置令牌

createHash 来自 Node.js 内置模块 node:crypto,无需安装额外依赖:

// src/auth/auth.service.ts
import { randomBytes, createHash } from "node:crypto";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { PasswordResetEntity } from "./entities/password-reset.entity";

@Injectable()
export class AuthService {
    constructor(
        // ...其他依赖...
        @InjectRepository(PasswordResetEntity)
        private readonly passwordResetRepo: Repository<PasswordResetEntity>,
    ) {}

    async requestPasswordReset(email: string): Promise<void> {
        const user = await this.usersService.findByEmail(email);

        // 防用户枚举:无论邮箱是否存在,返回外观相同的响应
        if (!user) {
            // 随机延迟,使响应耗时与真实流程趋于一致
            await new Promise((resolve) => setTimeout(resolve, 200 + Math.random() * 100));
            return;
        }

        // 生成高熵随机令牌(32 字节 = 256 位,CSPRNG,不可预测)
        const rawToken = randomBytes(32).toString("hex");

        // SHA-256 哈希:来自 node:crypto,无需第三方依赖
        const tokenHash = createHash("sha256").update(rawToken).digest("hex");
        const expiresAt = new Date(Date.now() + 30 * 60 * 1000); // 30 分钟有效

        // UPSERT:若该用户已有未使用的旧令牌,直接覆盖
        // 好处:用户多次点击"忘记密码"不会产生多个有效令牌,旧链接自动失效
        await this.passwordResetRepo.upsert(
            { userId: user.id, tokenHash, expiresAt, used: false },
            { conflictPaths: ["userId"] }, // 以 userId 为冲突检测键
        );

        // 邮件链接示例:https://example.com/reset-password?token=<rawToken>
        // rawToken 是 64 位十六进制字符串(32 字节 hex 编码)
        await this.emailService.sendPasswordReset(user.email, rawToken);
    }
}

5.4 消费令牌,完成密码重置

// src/auth/auth.service.ts(续)
import { DataSource } from "typeorm";

@Injectable()
export class AuthService {
    constructor(
        // ...其他依赖...
        private readonly dataSource: DataSource,
    ) {}

    async resetPassword(rawToken: string, newPassword: string): Promise<void> {
        // 密码强度与 HIBP 检测(与注册流程一致)
        const strength = checkPasswordStrength(newPassword);
        if (!strength.valid) {
            throw new BadRequestException({
                message: "密码强度不足",
                feedback: strength.feedback,
            });
        }
        const pwnedCount = await checkPasswordPwned(newPassword).catch(() => 0);
        if (pwnedCount > 0) {
            throw new BadRequestException("该密码已在历史泄露中出现,请更换");
        }

        // 将前端提交的 rawToken 哈希后,按 tokenHash 查询记录
        // 数据库中从未存过 rawToken 本身
        const tokenHash = createHash("sha256").update(rawToken).digest("hex");
        const record = await this.passwordResetRepo.findOne({
            where: { tokenHash },
        });

        // 三重验证:记录存在 + 未使用 + 未过期
        if (!record || record.used || record.expiresAt < new Date()) {
            throw new BadRequestException("重置链接无效或已过期");
        }

        const passwordHash = await hashPassword(newPassword);

        // 事务保证原子性:三个操作要么全部成功,要么全部回滚
        await this.dataSource.transaction(async (manager) => {
            // 1. 令牌标记为已使用,防止重放
            await manager.update(PasswordResetEntity, { id: record.id }, { used: true });
            // 2. 更新密码哈希
            await manager.update(UserEntity, { id: record.userId }, { passwordHash });
            // 3. 递增 tokenVersion,使该用户所有历史 JWT 立即失效(见上一篇"用户版本号"方案)
            await manager.increment(UserEntity, { id: record.userId }, "tokenVersion", 1);
        });

        // 事务成功后发通知邮件(非关键路径,失败不影响重置结果)
        await this.emailService.sendPasswordChangedNotice(record.userId).catch(() => void 0);

        this.logger.log("密码重置成功", { userId: record.userId });
    }
}

5.5 重置令牌安全属性清单

属性实现要点
高熵随机性randomBytes(32) 产生 256 位 CSPRNG 随机数,不可预测
短期有效30 分钟内有效,超时作废
一次性使用消费后立即标记 used=true,不可重复使用
存储安全数据库只存 SHA-256 哈希,泄露后攻击者无法直接使用令牌
新请求作废旧upsert 覆盖旧记录,用户多次请求不产生多个有效令牌
重置后失效密码修改后递增 tokenVersion,吊销所有历史 access/refresh token
通知用户重置成功发通知邮件,包含操作时间,让合法用户能发现异常

第六部分:密码修改接口

已登录用户修改密码,与重置流程不同,需要验证当前密码:

// src/auth/dto/change-password.dto.ts
import { IsString, MinLength, MaxLength } from "class-validator";

export class ChangePasswordDto {
    @IsString()
    currentPassword: string;

    @IsString()
    @MinLength(12)
    @MaxLength(128)
    newPassword: string;
}
// src/auth/auth.service.ts
async changePassword(
  userId: number,
  currentPassword: string,
  newPassword: string,
): Promise<void> {
  const user = await this.usersService.findById(userId);
  if (!user) throw new NotFoundException("用户不存在");

  // 验证当前密码
  const isCurrentValid = await verifyPassword(user.passwordHash, currentPassword);
  if (!isCurrentValid) {
    throw new UnauthorizedException("当前密码不正确");
  }

  // 禁止新旧密码相同
  const isSamePassword = await verifyPassword(user.passwordHash, newPassword);
  if (isSamePassword) {
    throw new BadRequestException("新密码不能与当前密码相同");
  }

  // 强度和 HIBP 检测
  const strength = checkPasswordStrength(newPassword);
  if (!strength.valid) {
    throw new BadRequestException({ message: "密码强度不足", feedback: strength.feedback });
  }
  const pwnedCount = await checkPasswordPwned(newPassword).catch(() => 0);
  if (pwnedCount > 0) {
    throw new BadRequestException("该密码已在历史泄露中出现");
  }

  const newHash = await hashPassword(newPassword);

  await this.dataSource.transaction(async (manager) => {
    await manager.update(UserEntity, { id: userId }, { passwordHash: newHash });
    await manager.increment(UserEntity, { id: userId }, "tokenVersion", 1);
    // 可选:在 Redis 中撤销该用户所有 refresh token
    await this.revokeAllUserRefreshTokens(userId);
  });

  await this.emailService.sendPasswordChangedNotice(userId).catch(() => void 0);
}
// src/auth/auth.controller.ts
@Post("change-password")
@HttpCode(HttpStatus.OK)
changePassword(
  @CurrentUser("sub") userId: number,
  @Body() dto: ChangePasswordDto,
) {
  return this.authService.changePassword(
    userId,
    dto.currentPassword,
    dto.newPassword,
  );
}

第七部分:整体安全加固清单

以下是本文涉及的所有安全措施的结构化清单,可用于代码审查和上线前自检:

密码存储

  • [ ] 使用 Argon2id,memoryCost ≥ 19456timeCost ≥ 2
  • [ ] 绝对不存储明文密码或可逆加密形式
  • [ ] 绝对不使用 MD5 / SHA-1 / SHA-256 直接哈希密码
  • [ ] 实现渐进式哈希升级(needsRehash

注册接口

  • [ ] 密码最短 12 位,最长 128 位
  • [ ] 密码强度多维度校验(长度 + 字符多样性 + 模式检测)
  • [ ] 集成 HIBP API 检测已泄露密码(超时降级,不阻断)
  • [ ] 唯一性冲突不区分"用户名"和"邮箱",统一错误信息
  • [ ] 注册接口独立限流(每 IP 每小时上限)

登录接口

  • [ ] 防时序攻击:用户不存在时执行占位 hash verify
  • [ ] 登录失败统一错误信息,不区分"用户不存在"和"密码错误"
  • [ ] 账号失败次数计数 + 临时锁定(Redis 实现)
  • [ ] 登录接口短时 + 长时双层限流
  • [ ] 反向代理场景配置 trust proxy,确保 IP 来源正确
  • [ ] 登录成功和失败均记录审计日志(含 IP、UA)

密码重置

  • [ ] 重置令牌使用 randomBytes(32) 生成,不可预测
  • [ ] 令牌 30 分钟内有效
  • [ ] 令牌只能使用一次
  • [ ] 数据库存储 SHA-256 哈希,不存原文
  • [ ] 重置成功后递增 tokenVersion,吊销所有历史 token
  • [ ] 重置成功发送邮件通知用户

密码修改

  • [ ] 修改前验证当前密码
  • [ ] 禁止新旧密码相同
  • [ ] 修改成功后吊销所有 refresh token,强制重新登录
  • [ ] 修改成功发送邮件通知

[/hide]

松声|廿六年·八月中·秋意近

作者 网友小宋
2026年8月15日 23:59

2026-八月半-超市风波.png

  • 我是一个不喜欢麻烦的人,在家附近的超市采购一些食材,买了三个大土豆,差不多一个土豆可以炒一盘菜的样子,中午做饭,其中一个土豆中间已经坏了,按照我的习惯,扔了即可,毕竟土豆这玩意不值钱,结果LD不开心,拿着小票去人家商超售后,人家给退钱了。
  • 写这个片段,只是想说,无论网购还是线下购,有问题一定要去争取,暴露问题才能解决问题。

2026-八月半-花.jpg

  • 车充电的地方离我几公里,充满去开车的路上遇到了一个凉亭,不知道开的什么花,挺好看的,路过几次了,它还这么依旧。

2026-八月半-考试.png

  • 已结束,我也没想到真到了考场我似乎没那么紧张,之前的紧张主要是源自于自己不懂的紧迫感,像是一个学习差的学生,只懂得一半的知识,另外一半只能靠死记硬背记。好在感谢B站的视频,以及自己的动手能力,一切顺利,考过之后虽然下雨,但是内心已经雨过天晴。

2026-八月半-大雨天.jpg
2026-八月半-大雨天2.png

  • 考过之后的确内心已经雨过天晴,大概紧张感是来自于报名有效期的接近,我都没准备一次能过,毕竟培训和考试接近十天,忘记不可怕,怕的是实操的生疏,毕竟技能这玩意不经常使用会生疏,幸好我提前总结了细节。Ld想吃葡萄,附近全是葡萄种植园,同事家的巨峰,10块钱4斤,买了三份,大狗二狗各一份,也是巧了,就剩了三份,还挺好吃的。
  • 回家之后,算是意识到了ld为什么不建议我回家,因为tmd台风来了,特别是回公司的时候,少部分路段给开船的一样。附近几个地方算是泄洪区,主流媒体也有报道,最近淹的老惨了,不过人没有啥问题,提前都有转移方案。这几年气候似乎发生了很大的变化,希望后面一起顺利吧!毕竟种地这玩意,真细算不挣钱。

2026-八月半-cc.jpg

  • 不建议买这家服务商当做主站使用,作为测试站点玩玩可以,巴拉特企业,人家邮件很正式的给你发送了条款,就是便宜机子我不管,死了就死了。上次已经看到一个博友全方面吐槽了。各位心中有谱吧!

[photos]
2026-八月半-作文1.jpg
2026-八月半-作文2.jpg
2026-八月半-作文3.jpg
[/photos]

  • 还没想好怎么写这一段,先推荐一个最近很火的作文“我的妈妈”,封面照处理的挺好的。
  • 思考这个片段如果写了一些不适合写的东西,很容易触发一些东西,前端时间经历过DNS被停止,还是要分开,毕竟子夜松声就是一个普通的Blogger,后续的后续还是看后续吧!
  • 最近抽空还是趁着有时间出去转转,大家也多出去转转哈,放松放松心情!

10 NestJS JWT 身份验证完全指南

作者 木灵鱼儿
2026年8月15日 21:53

NestJS JWT 身份验证完全指南

前言

JWT(JSON Web Token)是目前 RESTful API 身份验证领域的主流方案。其核心优势在于无状态性——服务端无需维护会话存储,token 本身即携带经签名保护的身份信息。

然而,仅理解 JWT 的基本签发与验证远远不够。在实际工程落地中,还需要系统性地考量以下问题:

  • 安全边界:payload 明文可解码,哪些字段可以放,哪些绝对不能放?
  • token 生命周期:单一长期 token 的泄露风险如何通过双 Token 策略加以控制?
  • 主动吊销:JWT 无状态的特性与"立即登出"的业务需求之间,如何取得平衡?
  • 密码安全:bcrypt 之外,为何 Argon2id 是当前更优的选择?
  • 配置安全:密钥、过期时间等敏感配置,如何在启动阶段完成强类型校验而非等到运行时才暴露错误?

本文基于 @nestjs/jwt 原生方案,不引入 Passport(后者在纯 JWT 场景下引入了额外的策略抽象层,增加理解成本而收益有限)。文章从模块骨架搭建出发,逐步深入到 Refresh Token Rotation、时序攻击防护、反向代理下的限流陷阱,最终给出一份覆盖 OWASP 核心要点的安全加固清单。

阅读本文需要具备 NestJS 模块化开发经验(依赖注入、守卫、装饰器),以及对 JWT 基本结构有初步认识。完成本文的实践后,你将得到一套结构清晰、类型安全的认证体系骨架,可直接在此基础上扩展 RBAC 权限模型或 OAuth2 三方登录。


[hide]

前置准备

安装所需依赖:

pnpm add @nestjs/jwt @nestjs/config @node-rs/argon2 class-validator class-transformer

依赖说明:

  • @nestjs/jwt:NestJS 官方 JWT 工具,封装 token 的签发与验证。
  • @nestjs/config:环境变量加载,配合 JwtModule.registerAsync 使用。
  • @node-rs/argon2:密码哈希库。选它不选 bcrypt 的原因:

    1. bcrypt 是 C++ 原生扩展,Windows 下需要 node-gyp + Python + 编译工具链,极易踩坑;原版 argon2 同理。
    2. @node-rs/argon2 基于 Rust,提供全平台预编译二进制,pnpm add 即用,性能也优于 bcrypt
    3. Argon2id 是 2015 年密码哈希竞赛冠军,在抗 GPU/ASIC 并行破解上强于 bcrypt,是目前的推荐算法。
  • class-validator / class-transformer:DTO 校验。

目录结构约定

在中大型项目中,守卫、装饰器、拦截器等横切关注点应放在 src/common 下,Auth 领域内部只保留自己的业务:

src/
├── common/
│   ├── decorators/
│   │   ├── current-user.decorator.ts
│   │   └── public.decorator.ts
│   └── guards/
│       └── jwt-auth.guard.ts
├── auth/
│   ├── dto/
│   │   └── sign-in.dto.ts
│   ├── types/
│   │   └── jwt-payload.type.ts
│   ├── auth.controller.ts
│   ├── auth.module.ts
│   └── auth.service.ts
├── users/
│   ├── users.module.ts
│   └── users.service.ts
├── config/
│   └── env.validation.ts
└── app.module.ts

这么拆的好处:JwtAuthGuard 会被 UsersModuleOrdersModule 等多个业务模块使用,放 common 目录避免循环依赖,同时职责清晰。


第一部分:创建身份验证模块

1. 生成模块骨架

nest g module auth
nest g controller auth --no-spec
nest g service auth --no-spec
nest g module users
nest g service users --no-spec

2. 环境变量与 JWT 配置

.env 文件:

# 至少 32 字节的随机字符串,可用 `openssl rand -base64 48` 生成
JWT_ACCESS_SECRET=your-access-secret-at-least-256-bits
JWT_ACCESS_EXPIRES_IN=15m

# refresh token 使用独立密钥,与 access token 隔离
JWT_REFRESH_SECRET=your-refresh-secret-different-from-access
JWT_REFRESH_EXPIRES_IN=7d

JWT_ISSUER=your-app-name
JWT_AUDIENCE=your-app-clients

借助 Zod 在应用启动时对环境变量做强类型校验,JWT 相关字段直接并入全局的 envSchema,无需单独的 jwt.config.ts

// src/config/env.validation.ts
import { z } from "zod";

const envSchema = z.object({
    // ... 其他字段
    JWT_ACCESS_SECRET: z.string().min(32, "ACCESS secret 至少 32 字节"),
    JWT_ACCESS_EXPIRES_IN: z.string().default("15m"),
    JWT_REFRESH_SECRET: z.string().min(32, "REFRESH secret 至少 32 字节"),
    JWT_REFRESH_EXPIRES_IN: z.string().default("7d"),
    JWT_ISSUER: z.string().optional(),
    JWT_AUDIENCE: z.string().optional(),
});

export type EnvConfig = z.infer<typeof envSchema>;

export function validateEnv(config: Record<string, unknown>): EnvConfig {
    const result = envSchema.safeParse(config);
    if (!result.success) {
        const messages = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n");
        throw new Error(`环境变量校验失败:\n${messages}`);
    }
    return result.data;
}

AppModule 中传入 validate 选项,应用启动时若缺少必填项则直接报错退出:

// src/app.module.ts(片段)
ConfigModule.forRoot({
    isGlobal: true,
    validate: validateEnv,
}),

具体逻辑可以参考之前的文章:《01 NestJS 环境变量与配置管理(Config 模块)》

3. 使用 registerAsync 注册 JwtModule

JwtModule 支持异步注册,直接从 ConfigService 读取环境变量,无需在源码中手动 process.env

// src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { UsersModule } from "../users/users.module";
import { EnvConfig } from "../config/env.validation";

@Module({
    imports: [
        UsersModule,
        JwtModule.registerAsync({
            inject: [ConfigService],
            useFactory: (config: ConfigService<EnvConfig, true>) => ({
                // 默认签发 access token 的配置
                secret: config.get("JWT_ACCESS_SECRET", { infer: true }),
                signOptions: {
                    expiresIn: config.get("JWT_ACCESS_EXPIRES_IN", { infer: true }),
                    issuer: config.get("JWT_ISSUER", { infer: true }), // iss:签发方,标识 token 来源
                    audience: config.get("JWT_AUDIENCE", { infer: true }), // aud:接收方,标识 token 使用者
                    algorithm: "HS256", // 默认即 HS256,显式声明便于审计
                },
                verifyOptions: {
                    issuer: config.get("JWT_ISSUER", { infer: true }), // 验证时校验 iss 与 aud,防止跨系统 token 复用
                    audience: config.get("JWT_AUDIENCE", { infer: true }),
                    algorithms: ["HS256"], // 显式限定算法,防止 alg=none 攻击
                },
            }),
        }),
    ],
    providers: [AuthService],
    controllers: [AuthController],
    exports: [AuthService, JwtModule], // 导出 JwtModule 供守卫所在模块复用
})
export class AuthModule {}

signOptions 常用配置一览:

字段作用推荐值
expiresIntoken 有效期,支持数字(秒)或 zeit/ms 字符串(15m7daccess 15m,refresh 7d ~ 30d
issueriss 标准字段,标识签发方。多系统场景下用于识别 token 来源应用名或域名
audienceaud 标准字段,标识 token 使用方。同一密钥签发的多种 token 可用它区分客户端标识
algorithm签名算法。对称加密用 HS256,非对称场景(微服务分发)用 RS256/ES256单体 HS256,跨服务 RS256
notBeforenbf 生效时间,token 在此之前不可用一般不用,特殊场景(预签发)才配置
jwtidjti 唯一 ID,配合黑名单可以主动吊销单枚 token需要吊销能力时开启,见"安全加固"
HS256 vs RS256:单体应用直接 HS256 即可;如果你有多个微服务需要验证 token,用 RS256——私钥只放签发服务,其他服务只需公钥即可验证,避免密钥扩散。

4. Payload 设计原则

先理解 JWT 的安全模型:

一个 JWT 由三段组成,用 . 分隔:

header.payload.signature
  • header / payload:Base64URL 编码,不是加密。任何人拿到 token 都能直接解码读取内容:

    JSON.parse(atob("eyJzdWIiOjEsInJvbGVzIjpbInVzZXIiXX0="));
    // { sub: 1, roles: ["user"] }
  • signature:服务端用 secret 对前两段做 HMAC 签名(注意:是签名,不是加密):

    HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

    验证时,服务端用同一个 secret 对收到的 header + payload 重新算一遍签名,再与第三段比对。

为什么篡改后无法伪造:

攻击者把 roles: ["user"] 改成 roles: ["admin"] 后,payload 变了,签名就对不上了。想伪造合法签名,必须知道 secret——而 secret 只在服务端。HMAC 也是不可逆的,无法从签名"推算"出 secret。

所以 JWT 的 payload 是 明文可解码 的(Base64URL 不是加密),因此设计原则非常严格:

推荐字段:

// src/auth/types/jwt-payload.type.ts
export interface JwtPayload {
    sub: number; // subject,用户唯一 ID(数据库主键),JWT 标准字段
    username: string; // 便于日志记录,不含敏感信息
    roles?: string[]; // RBAC 权限,避免每次请求都查库
    tokenType: "access" | "refresh"; // 区分 token 类型,防止 refresh 被当 access 使用
    // 以下由 JwtService 自动注入,无需手动设置
    iat?: number; // 签发时间
    exp?: number; // 过期时间
    iss?: string; // 签发方
    aud?: string; // 接收方
    jti?: string; // 唯一 ID
}

绝对不要放入 payload:

  • 密码、密码哈希
  • 支付信息、身份证、银行卡号
  • 大段的用户资料(头像、简介等,会撑大 token 体积)
  • 会频繁变动的数据(如用户当前余额)

设计原则:

  1. 越小越好:token 会随每个请求发送,HTTP header 有大小限制(一般 8KB),payload 控制在 1KB 以内。
  2. 只放不敏感、稳定、频繁使用的字段。
  3. 权限信息可放rolespermissions 放入 payload 可以避免守卫每次都查库,但修改权限后需要用户重新登录才能生效(这是 JWT 无状态的固有代价)。
  4. 敏感操作二次校验:转账、改密码等高危操作不要只依赖 JWT,应额外验证密码或短信验证码。

5. 实现 UsersService

真实项目中 UsersService 对接 TypeORM / Prisma / Mongoose,此处用内存数据演示:

// src/users/users.service.ts
import { Injectable } from "@nestjs/common";

export interface UserEntity {
    id: number;
    username: string;
    passwordHash: string;
    roles: string[];
}

@Injectable()
export class UsersService {
    private readonly users: UserEntity[] = [
        {
            id: 1,
            username: "john",
            // 原始密码: changeme(实际项目中从数据库读取)
            passwordHash: "$argon2id$v=19$m=65536,t=2,p=1$...",
            roles: ["user"],
        },
    ];

    async findByUsername(username: string): Promise<UserEntity | undefined> {
        return this.users.find((u) => u.username === username);
    }

    async findById(id: number): Promise<UserEntity | undefined> {
        return this.users.find((u) => u.id === id);
    }
}
// src/users/users.module.ts
import { Module } from "@nestjs/common";
import { UsersService } from "./users.service";

@Module({
    providers: [UsersService],
    exports: [UsersService], // 供 AuthService 与守卫使用
})
export class UsersModule {}

6. 实现 AuthService

AuthService 负责登录、签发 access + refresh token:

// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { verify } from "@node-rs/argon2";
import { randomUUID } from "node:crypto";
import { UsersService, UserEntity } from "../users/users.service";
import { JwtPayload } from "./types/jwt-payload.type";
import { EnvConfig } from "../config/env.validation";

export interface TokenPair {
    access_token: string;
    refresh_token: string;
    token_type: "Bearer";
    expires_in: number; // access token 剩余秒数,前端用于提前刷新
}

@Injectable()
export class AuthService {
    constructor(
        private readonly usersService: UsersService,
        private readonly jwtService: JwtService,
        private readonly configService: ConfigService<EnvConfig, true>,
    ) {}

    async signIn(username: string, pass: string): Promise<TokenPair> {
        const user = await this.usersService.findByUsername(username);

        // ⚠️ 时序旁路攻击防护(Timing Attack)
        // 问题:argon2 verify 耗时约 200~400ms。若用户不存在时直接返回错误(跳过 verify),
        //       攻击者可通过响应时间区分"用户不存在(2ms)"和"密码错误(300ms)",
        //       从而批量探测哪些用户名已注册——这叫"用户枚举"。
        // 解法:无论用户是否存在,都执行一次 verify。用户不存在时用占位 hash 凑足耗时,
        //       让两种情况的响应时间趋于一致,攻击者无法通过时间差区分。
        const passwordHash = user?.passwordHash ?? "$argon2id$v=19$m=65536,t=2,p=1$dummy";
        const isPasswordValid = await verify(passwordHash, pass);

        if (!user || !isPasswordValid) {
            throw new UnauthorizedException("用户名或密码错误");
        }

        return this.issueTokens(user);
    }

    private async issueTokens(user: UserEntity): Promise<TokenPair> {
        const basePayload: Omit<JwtPayload, "tokenType"> = {
            sub: user.id,
            username: user.username,
            roles: user.roles,
            jti: randomUUID(), // 唯一 ID,用于吊销
        };

        const [access_token, refresh_token] = await Promise.all([
            this.jwtService.signAsync(
                { ...basePayload, tokenType: "access" },
                {
                    secret: this.configService.get("JWT_ACCESS_SECRET", { infer: true }),
                    expiresIn: this.configService.get("JWT_ACCESS_EXPIRES_IN", { infer: true }),
                },
            ),
            this.jwtService.signAsync(
                { ...basePayload, tokenType: "refresh" },
                {
                    secret: this.configService.get("JWT_REFRESH_SECRET", { infer: true }),
                    expiresIn: this.configService.get("JWT_REFRESH_EXPIRES_IN", { infer: true }),
                },
            ),
        ]);

        return {
            access_token,
            refresh_token,
            token_type: "Bearer",
            expires_in: this.parseExpiresIn(
                this.configService.get("JWT_ACCESS_EXPIRES_IN", { infer: true }),
            ),
        };
    }

    private parseExpiresIn(value: string): number {
        const match = /^(\d+)([smhd])$/.exec(value);
        if (!match) return Number(value);
        const [, num, unit] = match;
        const map = { s: 1, m: 60, h: 3600, d: 86400 } as const;
        return Number(num) * map[unit as keyof typeof map];
    }
}

关键点:

  • 使用 ConfigService<EnvConfig, true> 注入配置,配合 Zod schema 在启动时校验环境变量,缺少必填项直接报错退出。
  • 无论用户存在与否都执行 verify,避免时序旁路攻击(timing attack)。
  • 每次登录生成新的 jti,为后续主动吊销打基础。

7. 实现登录接口

// src/auth/dto/sign-in.dto.ts
import { IsString, IsNotEmpty, MinLength, MaxLength } from "class-validator";

export class SignInDto {
    @IsString()
    @IsNotEmpty()
    @MinLength(3)
    @MaxLength(32)
    username: string;

    @IsString()
    @IsNotEmpty()
    @MinLength(8)
    @MaxLength(72)
    password: string;
}
// src/auth/auth.controller.ts
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { SignInDto } from "./dto/sign-in.dto";
import { Public } from "../common/decorators/public.decorator";

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

    @Public()
    @HttpCode(HttpStatus.OK)
    @Post("login")
    signIn(@Body() dto: SignInDto) {
        return this.authService.signIn(dto.username, dto.password);
    }
}

第二部分:实现身份验证守卫

1. 定义公共装饰器

// src/common/decorators/public.decorator.ts
import { SetMetadata } from "@nestjs/common";

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

2. 实现 JwtAuthGuard

守卫放在 common/guards 下,多个业务模块都能复用:

// src/common/guards/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { JwtService } from "@nestjs/jwt";
import { Request } from "express";
import { EnvConfig } from "../../config/env.validation";
import { JwtPayload } from "../../auth/types/jwt-payload.type";
import { IS_PUBLIC_KEY } from "../decorators/public.decorator";

@Injectable()
export class JwtAuthGuard implements CanActivate {
    constructor(
        private readonly jwtService: JwtService,
        private readonly reflector: Reflector,
        private readonly configService: ConfigService<EnvConfig, true>,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);
        if (isPublic) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const token = this.extractTokenFromHeader(request);
        if (!token) throw new UnauthorizedException("缺少认证 Token");

        try {
            const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
                secret: this.configService.get("JWT_ACCESS_SECRET", { infer: true }),
            });

            // 拒绝用 refresh token 访问业务接口
            if (payload.tokenType !== "access") {
                throw new UnauthorizedException("Token 类型错误");
            }

            request["user"] = payload;
        } catch {
            throw new UnauthorizedException("Token 无效或已过期");
        }

        return true;
    }

    private extractTokenFromHeader(request: Request): string | undefined {
        const [type, token] = request.headers.authorization?.split(" ") ?? [];
        return type === "Bearer" ? token : undefined;
    }
}

3. 注册为全局守卫

生产项目中绝大多数接口都需要认证,采用"默认保护 + @Public() 显式开放"的模式:

// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { AuthModule } from "./auth/auth.module";
import { UsersModule } from "./users/users.module";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";

@Module({
    imports: [ConfigModule.forRoot({ isGlobal: true }), AuthModule, UsersModule],
    providers: [
        {
            provide: APP_GUARD,
            useClass: JwtAuthGuard,
        },
    ],
})
export class AppModule {}

注册后,所有路由默认受保护,只有加 @Public() 装饰器的接口才能匿名访问。


第三部分:Token 刷新策略

为什么需要双 Token

单一长期 access token 有两个致命问题:

  1. 泄露风险:token 会随每个请求发送,一旦泄露(XSS、代理日志、错误上报),攻击者拥有的时间越长危害越大。
  2. 无法主动登出:JWT 无状态,服务端签发后无法撤回。

双 Token 方案

Token有效期传输方式存储位置作用
access_token15 分钟Authorization: Bearer <token>内存(前端变量)业务接口鉴权
refresh_token7 ~ 30 天HttpOnly + Secure + SameSite Cookie服务端 Redis(哈希后)兑换新的 access token

为什么这样设计:

  • access token 短期有效,即便泄露也只有 15 分钟窗口。
  • refresh token 存 HttpOnly Cookie,JavaScript 无法读取,避免 XSS 窃取。
  • refresh token 服务端存副本,登出时删除,可实现真正的主动吊销。
  • 前端拿到 access token 只放内存,刷新页面重新兑换,不用 localStorage 避免 XSS。

实现刷新接口

先在 UsersService 或独立的 TokenService 中存 refresh token 的哈希值。这里为清晰起见,直接在 AuthService 中扩展,实际项目建议拆分:

// src/auth/auth.service.ts(新增方法)
import { hash as argon2Hash, verify as argon2Verify } from "@node-rs/argon2";

// 假设注入了 Redis 客户端;也可以用数据库表 refresh_tokens 存储
constructor(
  private readonly usersService: UsersService,
  private readonly jwtService: JwtService,
  private readonly configService: ConfigService<EnvConfig, true>,
  @Inject("REDIS") private readonly redis: RedisClient,
) {}

private async storeRefreshToken(userId: number, jti: string, token: string) {
  // 存储哈希值而非原文,即使 Redis 泄露也无法直接用
  const tokenHash = await argon2Hash(token);
  const ttlSeconds = this.parseExpiresIn(this.configService.get("JWT_REFRESH_EXPIRES_IN", { infer: true }));
  await this.redis.setex(`refresh:${userId}:${jti}`, ttlSeconds, tokenHash);
}

async refreshTokens(refreshToken: string): Promise<TokenPair> {
  let payload: JwtPayload;
  try {
    payload = await this.jwtService.verifyAsync<JwtPayload>(refreshToken, {
      secret: this.configService.get("JWT_REFRESH_SECRET", { infer: true }),
    });
  } catch {
    throw new UnauthorizedException("Refresh token 无效或已过期");
  }

  if (payload.tokenType !== "refresh") {
    throw new UnauthorizedException("Token 类型错误");
  }

  // 校验服务端记录,实现主动吊销能力
  const storedHash = await this.redis.get(`refresh:${payload.sub}:${payload.jti}`);
  if (!storedHash) {
    throw new UnauthorizedException("Refresh token 已被撤销");
  }
  const isMatch = await argon2Verify(storedHash, refreshToken);
  if (!isMatch) {
    // 严重情况:token 有效但服务端记录对不上,可能是被复用
    // 业界做法:撤销该用户所有 refresh token(refresh token 轮转防复用)
    await this.revokeAllUserTokens(payload.sub);
    throw new UnauthorizedException("Refresh token 异常,请重新登录");
  }

  // 关键:一次性使用,用后即弃
  await this.redis.del(`refresh:${payload.sub}:${payload.jti}`);

  const user = await this.usersService.findById(payload.sub);
  if (!user) throw new UnauthorizedException("用户不存在");

  return this.issueTokens(user);
}

async logout(userId: number, jti: string) {
  await this.redis.del(`refresh:${userId}:${jti}`);
}

async revokeAllUserTokens(userId: number) {
  const keys = await this.redis.keys(`refresh:${userId}:*`);
  if (keys.length) await this.redis.del(...keys);
}

Refresh Token Rotation(轮转) 是业界公认的最佳实践:

  • 每次刷新都签发新的 refresh token,旧的立即作废。
  • 如果同一个 refresh token 被使用两次(合法用户 + 攻击者),第二次使用会失败,服务端应立即撤销该用户所有 token,强制重新登录。
  • OAuth 2.1 草案与 Auth0、Okta 等主流身份服务都采用此方案。

刷新接口

// src/auth/auth.controller.ts(新增)
import { Body, Controller, HttpCode, HttpStatus, Post, Res, Req } from "@nestjs/common";
import { Request, Response } from "express";
import { Public } from "../common/decorators/public.decorator";

@Public()
@HttpCode(HttpStatus.OK)
@Post("refresh")
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
  const refreshToken = req.cookies?.refresh_token;
  if (!refreshToken) throw new UnauthorizedException("缺少 refresh token");

  const tokens = await this.authService.refreshTokens(refreshToken);

  // 新的 refresh token 写回 HttpOnly Cookie
  res.cookie("refresh_token", tokens.refresh_token, {
    httpOnly: true,
    secure: true,          // 生产环境必须
    sameSite: "strict",    // 防 CSRF
    path: "/auth/refresh", // 限定路径,减少泄露面
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });

  return {
    access_token: tokens.access_token,
    token_type: tokens.token_type,
    expires_in: tokens.expires_in,
  };
}

登录接口同样应把 refresh token 写 Cookie,不把它放响应 body。


第四部分:类型安全的用户注入

这不是可选加分项,而是配合守卫使用的核心配套设施。直接操作 req.user 有两个问题:

  1. 类型不安全,需要在每个 controller 里手动断言。
  2. req.user 只有 payload,很多业务需要完整的用户实体(头像、邮箱、部门等)。

方案一:只从 payload 取(适合简单场景)

// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { JwtPayload } from "../../auth/types/jwt-payload.type";

export const CurrentUser = createParamDecorator(
    (
        field: keyof JwtPayload | undefined,
        ctx: ExecutionContext,
    ): JwtPayload | JwtPayload[keyof JwtPayload] => {
        const request = ctx.switchToHttp().getRequest();
        const user: JwtPayload = request.user;
        return field ? user?.[field] : user;
    },
);

使用:

@Get("profile")
getProfile(@CurrentUser() user: JwtPayload) {
  return user;
}

@Get("me/id")
getMyId(@CurrentUser("sub") userId: number) {
  return { userId };
}

优点:零开销,不查库。
缺点:只能拿到 payload 里的字段,扩展受限。

方案二:守卫中查库并挂载完整实体(业界通用做法)

Auth0、Clerk 等 SaaS 服务的 SDK,以及大厂后台通用做法:守卫验证 token 后,用 sub 查数据库拿到完整用户,挂到 req.user 上。这样 controller 拿到的是完整实体,包含最新的角色、状态等信息。

优点:能拿到完整用户信息,权限变更立即生效(不用等 token 过期)。
缺点:每个请求多一次 DB 查询,需要配合缓存。

改造守卫,用 UsersService 查询:

// src/common/guards/jwt-auth.guard.ts(增强版)
import { UsersService } from "../../users/users.service";

@Injectable()
export class JwtAuthGuard implements CanActivate {
    constructor(
        private readonly jwtService: JwtService,
        private readonly reflector: Reflector,
        private readonly usersService: UsersService,
        private readonly configService: ConfigService<EnvConfig, true>,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);
        if (isPublic) return true;

        const request = context.switchToHttp().getRequest<Request>();
        const token = this.extractTokenFromHeader(request);
        if (!token) throw new UnauthorizedException("缺少认证 Token");

        let payload: JwtPayload;
        try {
            payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
                secret: this.configService.get("JWT_ACCESS_SECRET", { infer: true }),
            });
        } catch {
            throw new UnauthorizedException("Token 无效或已过期");
        }

        if (payload.tokenType !== "access") {
            throw new UnauthorizedException("Token 类型错误");
        }

        // 查库拿完整实体,同时校验用户是否被禁用/删除
        const user = await this.usersService.findById(payload.sub);
        if (!user) throw new UnauthorizedException("用户不存在");
        // 例如: if (user.status === "banned") throw new ForbiddenException("账号已禁用");

        request["user"] = user;
        request["jwtPayload"] = payload;
        return true;
    }

    private extractTokenFromHeader(request: Request): string | undefined {
        const [type, token] = request.headers.authorization?.split(" ") ?? [];
        return type === "Bearer" ? token : undefined;
    }
}

由于 JwtAuthGuard 现在依赖 UsersService,需要在使用它的模块中导入 UsersModule。若守卫是全局注册(APP_GUARD),则将 UsersModule 设为全局或在 AppModule 中导入即可。

装饰器同步升级:

// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { UserEntity } from "../../users/users.service";

export const CurrentUser = createParamDecorator(
    (field: keyof UserEntity | undefined, ctx: ExecutionContext) => {
        const request = ctx.switchToHttp().getRequest();
        const user: UserEntity = request.user;
        return field ? user?.[field] : user;
    },
);

使用:

@Get("profile")
getProfile(@CurrentUser() user: UserEntity) {
  // user 是完整实体,含 email、avatar、roles 等
  return user;
}

性能优化:加缓存

守卫每次请求查库压力大,用 Redis 缓存用户信息,命中直接返回:

// 伪代码,实际可抽到 UsersService 内
async findByIdCached(id: number): Promise<UserEntity | null> {
  const cacheKey = `user:${id}`;
  const cached = await this.redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await this.prisma.user.findUnique({ where: { id } });
  if (user) {
    await this.redis.setex(cacheKey, 300, JSON.stringify(user)); // 缓存 5 分钟
  }
  return user;
}

修改用户信息时清缓存即可。这样兼顾了实时性与性能。

与 Prisma / TypeORM 的实际接入

以 Prisma 为例,UsersService.findById 的真实实现:

@Injectable()
export class UsersService {
    constructor(private readonly prisma: PrismaService) {}

    async findById(id: number) {
        return this.prisma.user.findUnique({
            where: { id },
            select: {
                id: true,
                username: true,
                email: true,
                roles: true,
                status: true,
                // 主动 select,避免返回 passwordHash 等敏感字段
            },
        });
    }
}

TypeORM 用户则可以在 Entity 上加 @Exclude({ toPlainOnly: true }) 装饰器排除敏感字段,配合 ClassSerializerInterceptor 使用。


第五部分:安全加固清单

1. 密码存储

生产环境务必使用 Argon2id,参数按硬件承受能力调整:

import { hash, verify, Algorithm } from "@node-rs/argon2";

// OWASP 推荐的最低参数(2024)
const HASH_OPTIONS = {
    algorithm: Algorithm.Argon2id,
    memoryCost: 19456, // 19 MiB
    timeCost: 2,
    parallelism: 1,
};

export async function hashPassword(plain: string) {
    return hash(plain, HASH_OPTIONS);
}

export async function verifyPassword(hashed: string, plain: string) {
    return verify(hashed, plain);
}

2. 传输层与 Cookie

  • 强制 HTTPS:生产环境必须全站 HTTPS,防中间人窃取 token。
  • Cookie 三件套:refresh token Cookie 必须 httpOnly: true + secure: true + sameSite: "strict"
  • CSP:配置 Content-Security-Policy 头,缓解 XSS 影响面。可用 helmet 包一键开启:
import helmet from "helmet";
app.use(helmet());

3. 登录限流与防暴力破解

@nestjs/throttler 对登录接口单独限流:

import { ThrottlerModule, Throttle } from "@nestjs/throttler";

// AppModule
imports: [
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),  // 全局:每分钟 100 次
],

// AuthController - 登录额外收紧
@Throttle({ default: { ttl: 60_000, limit: 5 } })  // 每分钟 5 次
@Public()
@Post("login")
signIn(@Body() dto: SignInDto) { /* ... */ }

进阶:按用户名 + IP 组合限流,命中阈值后要求验证码或临时锁定账号。

4. Token 吊销机制

JWT 天生无状态,主动吊销需要"有状态"支持。三种业界方案:

方案实现适用场景
短期 access + refresh 轮转access 15 分钟 + refresh 服务端存储绝大多数应用(推荐)
黑名单Redis 存已吊销的 jti,守卫查询需要立即吊销 access token
用户版本号payload 带 ver,改密码/登出时递增用户的 ver大量用户,黑名单成本高

用户版本号方案示例:

// payload
{ sub: 1, ver: 3, ... }

// 守卫中
const dbUser = await this.usersService.findById(payload.sub);
if (dbUser.tokenVersion !== payload.ver) {
  throw new UnauthorizedException("Token 已失效,请重新登录");
}

// 用户改密码/强制下线时
await this.usersService.incrementTokenVersion(userId);

只需一次 DB 查询就能全局失效该用户所有历史 token,比维护巨大的黑名单更优雅。

5. 密钥管理

  • 密钥长度:HS256 至少 32 字节;openssl rand -base64 48 生成。
  • access 与 refresh 使用不同密钥:即便 access 密钥泄露,refresh 仍安全。
  • 密钥存放:不要提交到 Git。开发用 .env,生产用密钥管理服务(AWS Secrets Manager、Vault、K8s Secret)。
  • 密钥轮换:定期更换密钥。切换期间可支持两个密钥并行验证,等旧 token 全部过期后再下线旧密钥。

6. 防用户枚举

登录失败统一返回相同错误信息与相近响应时间:

// ✅ 无论用户是否存在,都执行一次 argon2 verify,且返回相同错误
if (!user || !(await verify(user?.passwordHash ?? DUMMY_HASH, pass))) {
    throw new UnauthorizedException("用户名或密码错误");
}

注册接口也要注意:不要返回"该邮箱已注册",应改为发验证邮件"如果该邮箱已注册,我们已发送提示"。

7. 审计日志

关键动作打日志便于事后追溯:

  • 登录成功 / 失败(含 IP、UA)
  • 密码修改
  • refresh token 使用异常(触发轮转防复用)
  • 权限变更

结合 04 NestJS API版本控制策略.md03 NestJS 正确处理日志.md 中的日志方案落地。

8. 敏感字段脱敏

UsersService 返回给 controller 的用户对象绝对不能包含 passwordHash。Prisma 用 select,TypeORM 用 @Exclude,或统一在响应拦截器中 class-transformer 序列化——具体见 08 NestJS DTO 校验、Entity 脱敏与 Mapped Types 实战


[/hide]

6个星期的幼...

作者 阿和
2026年8月15日 16:44
6个星期的幼小衔接之旅,今日圆满收官[b099b]。 从握笔姿势到课堂纪律,从拼音启蒙到习惯养成,每一滴进步都闪闪发光✨。 知识会慢慢学,底气正在一点点积攒。。 带着满满的期待,九月,我们小学见[b059b]。
❌
❌