第 2.5 章 2.5.8:质量关 —— 安全审查、UI/UX 审查与踩坑复盘

第 2.5 章 2.5.8:质量关 ------ 安全审查、UI/UX 审查与踩坑复盘

课程专题 :《AI Agents 开发实践》

章节 :第 2.5 章 RBAC 用户权限系统

小节 :2.5.8 质量关与验收

作者 :一个从 Java 转型 AI 全栈的开发者

适合人群:所有人,尤其要自己动手、准备上线的读者


1. 功能跑通了,然后呢?

到上一篇为止,功能全部跑通了:能登录、能管用户、能分配权限、越权会 403。

但**"能跑"和"敢上线"之间,还差一个质量关。** 这一篇做两件"自检":

  1. 安全审查:假设我是攻击者,这套系统哪里能被我突破?
  2. 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。

原因(两层):

  1. 模块格式冲突 :Prisma 生成的 Client 是 CommonJS ,而 Monorepo 根配置可能是 ESM 。
    两边格式不一致,编译产物互相不认。
  2. 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 → 分阶段验收

最值得带走的不是代码,是三个习惯:

  1. 先划边界:需求澄清时写清"不做什么",比写清"做什么"更能防止返工。
  2. 一步一验收:每完成一小步就验证,不带病推进------这样你永远知道"哪里是对的"。
  3. 功能跑通后还要过质量关:安全审查和体验审查,是"能跑"到"敢用"之间的桥。

第 2.5 章是番外,但它练的是做任何生产系统都需要的功夫 。

下一章我们回到 AI 主线------开始给 Agent 装上"工具调用"的手和脚。


系列文章导航

相关推荐
尘世中一位迷途小书童1 小时前
P-256 那串神秘种子,其实是 1997 年的时钟跑出来的
安全
F&C嘉准传感器1 小时前
FC-SPY抗阳光槽型光电:户外仓储强光不失效,凝露受潮故障减少
安全·目标检测·自动化
chde2Wang1 小时前
hadoop部署错误-namenode address dfs.namenode.servicerpc-address not configured
java·大数据·hadoop
Solara1 小时前
一个标志位挂了 18 天不落地:它等的重启,2 天 14 小时后就完成了
人工智能·程序员·ai编程
xz2024102****1 小时前
agent基础知识:function calling,MCP,和Skill
大数据·人工智能·agent
格物致胜工业数智技术1 小时前
锂电纠偏测量那些坑,明治ETD-0612激光纠偏传感器怎么填?
人工智能·算法
远航计算机1 小时前
AI 答案里的链接,为什么开始分等级了?
大数据·人工智能·爬虫·aigc
LDG_AGI1 小时前
【口袋专家AI公开课】从0到1智能体开发(八):法律专家Agent原理与实战
人工智能
逐步走向自律1 小时前
运维手机应急 + 电脑多终端并行:2026 远程终端选型指南
人工智能·深度学习·机器学习·自然语言处理·数据科学