第 2.5 章 2.5.8:质量关 ------ 安全审查、UI/UX 审查与踩坑复盘
课程专题 :《AI Agents 开发实践》
章节 :第 2.5 章 RBAC 用户权限系统
小节 :2.5.8 质量关与验收
作者 :一个从 Java 转型 AI 全栈的开发者
适合人群:所有人,尤其要自己动手、准备上线的读者
1. 功能跑通了,然后呢?
到上一篇为止,功能全部跑通了:能登录、能管用户、能分配权限、越权会 403。
但**"能跑"和"敢上线"之间,还差一个质量关。** 这一篇做两件"自检":
- 安全审查:假设我是攻击者,这套系统哪里能被我突破?
- UI/UX 审查:假设我是第一次用的人,哪里会让我困惑、卡住?
然后是踩坑复盘------那些卡了我半小时以上的问题。
2. 安全审查:揪出两个 MEDIUM 问题
我拿这套系统当"别人的代码"来审,从最危险的地方开始:认证密钥 和提权路径。
2.1 问题一:JWT 密钥的源码级弱 fallback(MEDIUM)
先看修复前的代码长什么样:
typescript
// ❌ 修复前(危险)
export const jwtConstants = {
accessSecret: process.env.JWT_ACCESS_SECRET ?? 'dev-access-secret',
refreshSecret: process.env.JWT_REFRESH_SECRET ?? 'dev-refresh-secret',
accessExpiresIn: '2h',
refreshExpiresIn: '7d',
} as const;
这段代码"看起来很贴心":环境变量没配?给你个默认值,服务照样能起来。
但它是一个静默的安全降级:
- 生产环境漏配
JWT_ACCESS_SECRET - 服务照常启动(不会报错,没人会发现)
- 密钥就是源码里公开的
'dev-access-secret' - 任何看过这段源码的人,都能用它自签一个
isSuperAdmin: true的 Token
结果是:整个 RBAC 系统被一行"贴心的 fallback"彻底绕过 。攻击者不需要登录,
自己签个超管 Token 就进来了。
为什么危险等级是 MEDIUM 而不是 HIGH? 因为它依赖"漏配环境变量"这个前提,
不是无条件可利用。但一旦触发,后果是灾难性的------权限系统形同虚设。
修复:删掉 fallback,改成启动时 fail-fast。
typescript
// ✅ 修复后:不再提供代码级 fallback------以前 `?? 'dev-access-secret'` 看似方便,
// 实则是"静默降级":漏配环境变量时服务照常启动,攻击者可用公开的默认值自签任意超管 Token。
export const jwtConstants = {
accessSecret: process.env.JWT_ACCESS_SECRET!,
refreshSecret: process.env.JWT_REFRESH_SECRET!,
accessExpiresIn: '2h',
refreshExpiresIn: '7d',
} as const;
配合 main.ts 里的启动校验:
typescript
// fail-fast:JWT 密钥缺失或仍是占位值时拒绝启动。
for (const key of ['JWT_ACCESS_SECRET', 'JWT_REFRESH_SECRET']) {
const value = process.env[key];
if (!value || value.length < 32 || value.includes('dev-')) {
throw new Error(
`环境变量 ${key} 未配置或为占位值(需 ≥32 位高熵随机串,如 openssl rand -hex 32)`,
);
}
}
核心思想:fail-fast------宁可服务起不来,也不能带着弱密钥跑。
一个"起不来"的报错会立刻被运维发现并修复;一个"能用但密钥是公开的"的系统,
可能跑几个月都没人知道。显式失败 > 静默降级。
生成密钥:
bash
openssl rand -hex 32
# 输出一个 64 字符的随机串,填进 .env
Java 对照 :Spring Boot 里
@Value("${jwt.secret:defaultSecret}")就是同一个坑。凡是"敏感配置"(密钥、数据库密码),都不该有默认值 ,
而应该在启动时用
@PostConstruct校验并拒绝启动。这是通用经验。
附带效果 :换密钥后,所有旧 Token 全部失效 (验不过签名)。
这是预期行为------安全修复本就该让旧凭证作废。
2.2 问题二:创建用户接口的提权通道(MEDIUM)
第二个问题更隐蔽。 看 CreateUserDto 修复前的形状------里面有一个 isSuperAdmin 字段。
问题在于后端创建用户时,如果直接把请求体的字段透传给数据库 ,
那么就形成了一条提权通道:
攻击场景:
某个持有 system:user:create 权限的非超管用户(比如普通管理员)
→ 调 POST /api/users,请求体里偷偷带上 { "isSuperAdmin": true }
→ 后端透传 → 数据库里多了一个超管
→ 他给这个新账号设个密码,登录 → 绕过整个 RBAC
持有"创建用户"权限,就能"创建超管"------这是权限系统的严重越权漏洞。
修复分两步:
第一步,CreateUserDto 里不声明 isSuperAdmin 字段。它只有:
typescript
export class CreateUserDto {
@Matches(/^[a-zA-Z][a-zA-Z0-9_]{2,19}$/, { message: '用户名须以字母开头,3~20 位字母/数字/下划线' })
username!: string;
@MinLength(8, { message: '密码不能少于 8 位' })
@Matches(/^(?=.*[a-zA-Z])(?=.*\d).{8,}$/, { message: '密码须同时包含字母和数字' })
password!: string;
@Optional() @MaxLength(30)
nickname?: string;
// 没有 isSuperAdmin
}
第二步,服务层不透传 isSuperAdmin:
typescript
// users.service.ts ------ 创建用户
const user = await this.prisma.user.create({
data: {
username: dto.username,
passwordHash,
nickname: dto.nickname ?? dto.username,
// 安全审查修复:不接受请求体指定 isSuperAdmin------
// 否则持有 system:user:create 权限的角色可静默制造超管,绕过整个 RBAC。
// 超管标志只由 seed / 数据库运维维护,schema 默认 false。
},
// ...
});
配合 main.ts 里的 ValidationPipe({ whitelist: true })------
请求体里多出来的 isSuperAdmin 会被自动剔除,永远到不了服务层。
这里有个容易踩的坑,必须提醒: whitelist 是静默丢弃 多余字段,
不会报错 。所以接口仍然返回 201 创建成功,只是那个字段被忽略了。
验收时如果只看状态码,会误以为"提权成功了"。必须看数据库里的实际数据 ------
新建的用户 isSuperAdmin 是 false,才算修复生效。
Java 对照 :这对应 Spring 的 DTO 绑定------如果 Controller 直接接收实体对象
(@RequestBody User user),用户就能覆盖任何字段。正确做法是用专门的 DTO
只接允许的字段,敏感字段(如
role、enabled、isSuperAdmin)绝不从请求体读。这叫 "DTO 白名单"原则 :只接你需要的东西,其余一律不接。
2.3 安全审查小结
| # | 问题 | 等级 | 修复 | 通用经验 |
|---|---|---|---|---|
| 1 | JWT 密钥源码弱 fallback | MEDIUM | 删 fallback + 启动 fail-fast | 敏感配置不要默认值,宁可起不来 |
| 2 | 创建用户可传 isSuperAdmin |
MEDIUM | DTO 不声明 + 服务层不透传 | DTO 白名单,敏感字段绝不从请求体读 |
这两个问题都属于同一类:"看着方便、实则危险"的隐式行为 。
安全审查的价值,就是把这类"没人会主动怀疑"的地方翻出来。
3. UI/UX 审查:12 项体验改进
功能安全了,再看体验。这一轮审查聚焦"可访问性"和"确定性"两方面,
落地了一批改进(都是小改动,但累积起来感受差别很大):
3.1 可访问性(Accessibility)
| # | 改进 | 位置 |
|---|---|---|
| 1 | 登录错误用 role="alert" + aria-live="assertive",屏幕阅读器主动播报 |
登录页 |
| 2 | 侧边栏当前项加 aria-current="page" |
后台布局 |
| 3 | 状态开关加 aria-label("启用或禁用 xxx"),不再是裸开关 |
用户页 |
| 4 | 表格加 aria-label="用户列表" |
用户页 |
| 5 | 页面级加载失败用 role="alert" 播报 |
用户页 |
3.2 对比度与可读性
| # | 改进 | 说明 |
|---|---|---|
| 6 | 辅助文字从 12px 浅灰 → 14px 更深色 | 12px 灰字不达 4.5:1 对比度 |
| 7 | 创建时间列用 text-default-600(更深) |
同上,保证可读 |
| 8 | 说明文字统一提到 14px | 侧边栏底部文字等 |
WCAG 的对比度标准是 4.5:1(正文)。小字号 + 浅灰色是最常见的"不达标"组合。
3.3 确定性与反馈
| # | 改进 | 说明 |
|---|---|---|
| 9 | 列表加载用骨架屏替代转圈 | 骨架屏稳定行高,避免布局跳动(CLS) |
| 10 | 空态给引导文案 | "暂无用户,点击右上角「新增用户」创建" |
| 11 | 搜索无结果给具体提示 | "未找到匹配「{关键词}」的用户",而不是空表 |
| 12 | 提交按钮加 isLoading 状态 |
防止重复提交,也给用户"正在处理"的反馈 |
骨架屏为什么比转圈好? 因为转圈是"一个点",页面高度会随数据加载而突然塌陷或撑开 ------
用户刚要点的按钮"跳"走了。骨架屏提前占好位置 ,加载完原地替换,视觉是稳的。
这是一个"感知性能"的经典优化。
3.4 一个"复用性"改进
错误信息统一走 apiErrorMessage()(第 04 篇的工具函数),
直接把后端的话显示给用户 ("用户名或密码错误"),而不是笼统的"请求失败"。
后端已经写好了可读的提示,前端别再翻译一遍。
4. 踩坑复盘
这一章踩的坑不少,每个都卡了我半小时以上。按"现象 → 原因 → 解决"记录。
坑 1:TS2742「类型无法被命名」
现象 :bun run typecheck 报错 TS2742: The inferred type of 'x' cannot be named without a reference to ...。
原因 :某个变量的类型是推断 出来的(比如 Prisma Client 的返回类型),
而该类型定义在依赖包的深层路径 里(node_modules/.pnpm/.../client.d.ts)。
TypeScript 想给变量生成 .d.ts 声明,却"找不到那个类型的稳定名字",于是报错。
解决 :给变量显式标注类型 ,或者把返回类型显式声明出来,不让 TS 去推断那个"无法命名"的类型。
根本上,跨包引用类型时,让被引用的包导出明确的类型 (我们 @autix/database 里
export * from '@prisma/client' 就是在做这件事)。
Java 对照:有点像泛型擦除后类型无法表达时的编译错误------都是"编译器需要明确的类型信息"。
坑 2:HeroUI 的样式扫不出来(Turbopack + Bun 隔离模式)
现象 :页面结构渲染了,但 HeroUI 组件的样式全丢了------按钮是裸 HTML。
原因 :Tailwind 4 默认不扫描 node_modules (被 .gitignore 排除)。
HeroUI 的组件类名都在它自己的包里,所以 Tailwind 生成 CSS 时"看不到"这些类,样式自然缺失。
需要显式用 @source 声明扫描路径。
但坑在路径怎么写 :在 Bun 的 isolated 模式 下,@heroui/theme 以符号链接 出现在
本包的 node_modules 里;如果用根目录 .bun 下的哈希目录 写通配路径,扫不出文件。
解决:
css
/* globals.css */
@import "tailwindcss";
@plugin "./hero.ts";
/* Tailwind 默认不扫描 node_modules(被 .gitignore 排除),
HeroUI 的组件类名需要显式声明扫描路径。
注意:@heroui/theme 是直接依赖------bun isolated 模式下它会以符号链接出现在
本包的 node_modules 里,路径稳定;不要用根目录 .bun 的哈希目录(通配不可靠) */
@source "../../node_modules/@heroui/theme/dist/**/*.{js,mjs,ts,jsx,tsx}";
关键 :@source 指向本包 node_modules 下稳定的符号链接路径 ,
不要指向 .bun 的哈希目录。
坑 3:Turbopack 缓存------改了配置不生效
现象 :改了 globals.css 的 @source,重启 dev server 后样式还是没变。
原因 :Turbopack 有构建缓存,配置类文件改动不一定被识别为"需要重新生成"。
解决 :删掉 .next 目录再重启 dev server:
bash
rm -rf .next
bun run dev
Java 对照 :类似 Maven/Gradle 的增量构建缓存偶尔失效,
mvn clean一把梭。改配置类的东西(尤其构建工具配置)后,清缓存是最省心的排查手段。
坑 4:Prisma 在 NestJS 里的 ESM / CommonJS 与 rootDir 冲突
现象 :user-system 编译报错------模块找不到,或者 rootDir 不允许引用 packages/database。
原因(两层):
- 模块格式冲突 :Prisma 生成的 Client 是 CommonJS ,而 Monorepo 根配置可能是 ESM 。
两边格式不一致,编译产物互相不认。 - rootDir 冲突 :TypeScript 的
rootDir默认不允许"跨包引用"------
引用packages/database相当于把编译范围扩到了services/user-system之外。
解决:
json
// services/user-system/tsconfig.json 的思路
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs", // 与 Prisma Client 对齐
"moduleResolution": "node", // CommonJS 用 node 解析,不用 bundler
...
}
}
- 把服务的
module明确设为commonjs、moduleResolution设为node,
与 Prisma 生成物对齐; - 引用共享包时,指向它的**编译产物(
dist)**而不是源码,避免rootDir冲突。
Java 对照 :这就像 Maven 多模块里,A 模块依赖 B 模块时,
要么 B 已经被
mvn install到本地仓库,要么配置好 reactor 构建顺序------你不能直接"引用 B 的源码目录却指望 A 单独编译"。
坑 5:PowerShell 5.1 发中文请求体,存储成乱码
现象:用 PowerShell 调创建用户接口,传中文昵称,数据库里存进去的是乱码。
原因 :Windows PowerShell 5.1 发送请求体时默认用 GBK / 系统本地编码 ,
而服务端按 UTF-8 解析------编码不一致,中文就花了。
解决 :验收时改用 ASCII 内容 (用户名、昵称都用英文),或换用 PowerShell 7 / curl。
本章的验收脚本统一用英文参数,避开这个坑。
Java 对照 :经典的文件编码问题------读文件用错 charset 就乱码。
HTTP 传输的编码,永远显式指定 UTF-8 (
Content-Type: application/json; charset=utf-8)。
坑 6:Next.js 16 缺少根布局 / 配置版本不兼容
现象 :Next.js 16 启动报"缺少根布局",或 next.config.ts 因版本差异报错。
原因 :Next.js 的 App Router 版本迭代中,
根布局 layout.tsx 变成强制要求 (必须有一个根 layout.tsx 包住 <html> / <body>),
且某些配置项的写法随大版本变化。
解决 :保证 src/app/layout.tsx 存在(本章第 04 篇那段根布局代码),
并让 next.config.ts 与该版本对齐。
Java 对照 :框架大版本升级时的"配置项改名/废弃"------Spring Boot 2→3 的
spring.jpa.hibernate.ddl-auto之类配置迁移,是同一类问题。
踩坑复盘小结
| # | 坑 | 一句话根因 | 关键词 |
|---|---|---|---|
| 1 | TS2742 | 推断类型无法命名 | 显式标注类型 / 导出类型 |
| 2 | HeroUI 样式丢失 | Tailwind 不扫 node_modules | @source 指向稳定路径 |
| 3 | 配置改了不生效 | Turbopack 缓存 | 删 .next 重启 |
| 4 | Prisma 编译报错 | ESM/CJS + rootDir 冲突 | module: commonjs,引用 dist |
| 5 | 中文乱码 | PS 5.1 用 GBK 发 body | 验收用 ASCII / 指定 UTF-8 |
| 6 | Next 缺根布局 | App Router 强制根 layout | 补 app/layout.tsx |
规律 :前三个都和构建/打包层 有关,后三个和跨环境/跨版本 有关。
这类"配置坑"的共同解法是------理解它在哪个环节出问题,而不是照着报错关键词瞎改。
5. 最终验收:8 条标准逐条打勾
功能 + 安全 + 体验全部收口后,按 SPEC 第 9 节的清单逐条验收:
| # | 验收标准 | 结果 | 验证方式 |
|---|---|---|---|
| 1 | 登录返回 accessToken / refreshToken | ✅ | curl 登录 |
| 2 | /auth/profile 返回用户、菜单、权限 |
✅ | curl 带 Token 请求 |
| 3 | 有权限接口 200;无权限接口 403(不是 401) | ✅ | testuser 调创建用户 |
| 4 | super_admin 跳过权限检查 | ✅ | admin 调任意接口 |
| 5 | 禁用用户无法登录;refresh 时也无法续签 | ✅ | 禁用后 login 401、旧 refresh 续签 401 |
| 6 | accessToken 过期后前端自动续签重放 | ✅ | 手改 Token 访问接口 |
| 7 | 菜单 / 按钮按权限动态显隐 | ✅ | admin 与 testuser 对比 |
| 8 | 权限分配界面可正常保存并即时生效 | ✅ | 给 user 角色加权限,重登后菜单变化 |
8/8 全部通过。 其中第 3 条(越权返回 403 而不是 401)和第 6 条(无感续签)
是本章最核心的两条------它们分别验证了"后端鉴权"和"前端续签"两条链路的正确性。
6. 全章收尾:这套东西到底值不值
回头看第 2.5 章做的事情:
| 维度 | 产出 |
|---|---|
| 数据库 | 5 张表 + 迁移 + 幂等种子数据 |
| 后端 | 双 Token 认证 + 全局守卫 + 声明式权限 |
| 前端 | 无感续签 + 动态菜单 + 按钮级权限 |
| 质量 | 2 个安全修复 + 12 项体验改进 + 6 个踩坑复盘 |
| 流程 | 需求澄清 → Spec → Plan → 分阶段验收 |
最值得带走的不是代码,是三个习惯:
- 先划边界:需求澄清时写清"不做什么",比写清"做什么"更能防止返工。
- 一步一验收:每完成一小步就验证,不带病推进------这样你永远知道"哪里是对的"。
- 功能跑通后还要过质量关:安全审查和体验审查,是"能跑"到"敢用"之间的桥。
第 2.5 章是番外,但它练的是做任何生产系统都需要的功夫 。
下一章我们回到 AI 主线------开始给 Agent 装上"工具调用"的手和脚。
系列文章导航
- 上一篇:05 2.5.7 动态菜单与按钮级权限(./05-2.5.7 动态菜单与按钮级权限.md)
- 下一篇:第三章 AI Agent 核心:工具调用与工作流(待发布)
- 回到索引 :第 2.5 章博客索引