6-18笔记
*一、整体知识脉络*
课次27 ─→ 课次28 ─→ 课次29 ─→ 课次30
统一返回 JWT认证 拦截器校验 业务CRUD
全局异常 登录接口 ThreadLocal 权限控制
逻辑链条:
\1. 先规范API返回格式(统一封装),让前后端对接有标准
\2. 再做用户认证(JWT),让用户能登录获取令牌
\3. 然后做接口保护(拦截器),让未登录用户访问不了业务接口
\4. 最后实现业务功能(新闻CRUD),在保护下完成增删改查
*二、课次27:统一结果封装 & 全局异常处理*
*2.1 为什么要统一返回格式?*
不同接口返回格式混乱,前端难以处理:
• 有的返回 { "msg": "ok", "data": ... }
• 有的返回 { "status": 200, "result": ... }
• 有的直接返回裸数据
统一标准格式:
// json**
**{
"code": 200,
"message": "success",
"data": { ... }
}
*2.2 Result 类设计(泛型封装)*
// java**
**@Data
public class Result
private Integer code; // 状态码:200成功,500失败
private String message; // 提示信息
private T data; // 返回数据(泛型)
// 私有构造,防止外部new
private Result(Integer code, String message, T data) { ... }
// 静态工厂方法(核心设计模式)
public static
public static
public static
}
关键知识点:
• 使用泛型
• 构造方法私有化 + 静态工厂方法,调用更简洁
• 使用 Lombok @Data 自动生成 getter/setter
调用方式:
// java**
**// 成功 - 带数据
return Result.success(userList);
// 成功 - 只带消息
return Result.success("操作成功");
// 失败
return Result.error("用户名或密码错误");
*2.3 全局异常处理器*
问题: 如果代码中抛出异常,会返回什么?
• 默认返回Spring的白色标签页(HTML),前端无法处理
解决: @RestControllerAdvice + @ExceptionHandler
// java**
**@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public Result<?> handleRuntimeException(RuntimeException e) {
e.printStackTrace(); // 打印堆栈(方便调试)
return Result.error(e.getMessage()); // 返回统一格式的错误
}
}
原理:
• @RestControllerAdvice = @ControllerAdvice + @ResponseBody
• 它会"拦截"所有Controller抛出的异常
• @ExceptionHandler(RuntimeException.class) 指定处理哪类异常
• 可以定义多个 @ExceptionHandler 处理不同类型的异常
执行流程:
Controller抛出RuntimeException
↓
GlobalExceptionHandler 捕获
↓
返回 Result.error(e.getMessage())
↓
前端收到 { code: 500, message: "xxx", data: null }
*2.4 Postman 测试要点*
• 新建 Collection 组织请求
• GET 请求直接填 URL 点 Send
• 返回结果在 Body 中选 JSON 格式查看
*三、课次28:JWT工具类 & 登录接口*
*3.1 JWT 核心概念*
JWT(JSON Web Token) = 无状态认证方案
传统Session vs JWT:
| 对比项 | Session | JWT |
|---|---|---|
| 存储位置 | 服务端 | 客户端 |
| 状态 | 有状态 | 无状态 |
| 扩展性 | 差(分布式问题) | 好(服务端不存状态) |
| 安全性 | 较高 | 中等(需保护密钥) |
JWT 三段结构:
Header.Payload.Signature
↓ ↓ ↓
算法信息 用户数据 签名(防篡改)
*3.2 依赖配置*
// xml
为什么需要 jaxb-api?
JDK 9 开始移除了 javax.xml.bind 模块,而 JJWT 内部使用了它来做 Base64 编解码。
*3.3 application.yml 配置*
// yaml**
**jwt:
secret: weitoutiao_secret_key_2025 # 签名密钥(不能泄露!)
expire: 604800000 # 过期时间(毫秒)= 7天
使用 @Value 注入配置:
// java**
**@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private Long expire;
*3.4 JwtUtil 工具类(核心)*
// java**
**@Component
public class JwtUtil {
// 生成Token
public String generateToken(Integer userId, String username) {
Date now = new Date();
Date expiration = new Date(now.getTime() + expire);
return Jwts.builder()
.setSubject(String.valueOf(userId)) // 主题:用户ID
.claim("username", username) // 自定义声明:用户名
.setIssuedAt(now) // 签发时间
.setExpiration(expiration) // 过期时间
.signWith(SignatureAlgorithm.HS256, secret) // 签名算法+密钥
.compact(); // 生成字符串
}
// 解析Token
public Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}
// 从Token提取用户ID
public Integer getUserIdFromToken(String token) {
return Integer.parseInt(parseToken(token).getSubject());
}
// 验证Token是否有效
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
return false; // 过期、篡改、格式错误 → 返回false
}
}
}
关键方法解析:
| 方法 | 作用 | 返回 |
|---|---|---|
| generateToken | 根据用户信息生成token | token字符串 |
| parseToken | 解析token获取Claims | Claims对象 |
| getUserIdFromToken | 从token提取用户ID | Integer |
| validateToken | 验证token是否有效 | boolean |
*3.5 登录接口完整流程*
前端发送 POST /user/login
↓
接收 UserLoginDTO(username, password)
↓
UserService.login(username, password)
↓
LambdaQueryWrapper 构建查询条件
↓
数据库查询:username = ? AND password = ?
↓
┌── 查到用户 → JwtUtil.generateToken() → 返回token
└── 未查到 → 返回null → Result.error("用户名或密码错误")
Service 层代码要点:
// java**
**// MyBatis-Plus 的 LambdaQueryWrapper(类型安全,避免写错字段名)
LambdaQueryWrapper
wrapper.eq(User::getUsername, username)
.eq(User::getPassword, password);
User user = this.getOne(wrapper);
注意: this.getOne() 要求查询结果唯一,如果有多条会报错。这里 username 应该是唯一的。
3.6 DTO 设计
** java**
@Data
public class UserLoginDTO {
private String username;
private String password;
}
为什么要用 DTO?
不直接暴露实体类(Entity)给前端
可以控制前端能传哪些字段(安全性)
可以做字段校验(后续可加 @NotBlank 等注解)
四、课次29:拦截器 Token 校验 + ThreadLocal
4.1 为什么需要拦截器?
问题: 登录接口返回了 token,但其他接口怎么知道用户已登录?
解决: 每次请求都在拦截器中验证 token。
拦截器执行位置:
请求 → 拦截器(preHandle) → Controller → 拦截器(afterCompletion) → 响应
↑ ↑
验证token,通过才放行 清理ThreadLocal
4.2 ThreadLocal 是什么?
一句话: 每个线程的"私有储物柜",线程之间互不干扰。
为什么需要 ThreadLocal?
请求A(用户1)──→ Thread-1 ──→ 需要知道当前用户是1
请求B(用户2)──→ Thread-2 ──→ 需要知道当前用户是2
如果用一个全局变量存用户ID,多个请求会互相覆盖!
ThreadLocal 让每个线程有自己的副本,互不影响。
使用方式:
** java**
public class UserContext {
// ThreadLocal
private static final ThreadLocal
// 设置当前用户ID(拦截器中调用)
public static void setCurrentUserId(Integer userId) {
currentUserId.set(userId);
}
// 获取当前用户ID(Controller/Service中调用)
public static Integer getCurrentUserId() {
return currentUserId.get();
}
// 清除(必须!防止内存泄漏)
public static void clear() {
currentUserId.remove();
}
}
⚠️ 重要:必须在请求结束后调用 clear(),否则线程池复用线程时会导致数据泄漏!
4.3 JwtInterceptor 拦截器
java
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
1. 从请求头获取 token
String token = request.getHeader("Authorization");
2. 检查格式:Bearer
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7); // 去掉 "Bearer " 前缀
3. 验证 token
if (jwtUtil.validateToken(token)) {
4. 提取用户ID,存入 ThreadLocal
Integer userId = jwtUtil.getUserIdFromToken(token);
UserContext.setCurrentUserId(userId);
return true; // 放行
}
}
5. token无效 → 返回401
response.setStatus(401);
response.setContentType("application/json;charset=utf-8");
response.getWriter().write("{"code":401,"message":"未登录或token失效"}");
return false; // 拦截
}
@Override
public void afterCompletion(...) {
UserContext.clear(); // ⚠️ 必须清理!
}
}
请求头格式(HTTP标准):
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx
4.4 拦截器配置
** java**
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/**") // 拦截所有请求
.excludePathPatterns("/user/login", "/user/register"); // 排除登录注册
}
}
配置逻辑:
• addPathPatterns("/**"):拦截所有路径
• excludePathPatterns(...):白名单,不需要登录就能访问的接口
4.5 完整认证流程
\1. 用户登录 → POST /user/login → 返回 token
\2. 前端保存 token(localStorage / cookie)
\3. 后续请求:
请求头加 Authorization: Bearer
↓
\4. 拦截器 preHandle:
├── 提取 token
├── 验证有效性
├── 提取 userId → 存入 ThreadLocal
└── 放行 / 拦截
↓
\5. Controller 中:
Integer userId = UserContext.getCurrentUserId();
↓
\6. 请求结束 → afterCompletion → UserContext.clear()
五、课次30:新闻模块 CRUD
5.1 RESTful API 设计规范
| 操作 | HTTP方法 | URL | 说明 |
|---|---|---|---|
| 创建 | POST | /news/add | 提交新资源 |
| 查询列表 | GET | /news/page | 获取资源列表 |
| 查询详情 | GET | /news/ | 获取单个资源 |
| 修改 | PUT | /news/ | 更新资源 |
| 删除 | DELETE | /news/ | 删除资源 |
RESTful 核心思想: URL 代表资源,HTTP方法代表操作。
5.2 新闻发布(Create)
java
@PostMapping("/add")
public Result<?> addNews(@RequestBody NewsAddDTO dto) {
// 从 ThreadLocal 获取当前用户ID(拦截器已经设置好了)
Integer userId = UserContext.getCurrentUserId();
if (userId == null) return Result.error("请先登录");
News news = new News();
news.setUserId(userId); // 关联当前用户
news.setTitle(dto.getTitle());
news.setContent(dto.getContent());
news.setViewCount(0); // 初始浏览量为0
return newsService.save(news)
? Result.success("发布成功")
: Result.error("发布失败");
}
要点:
@RequestBody 接收 JSON 请求体,自动反序列化为 DTO
UserContext.getCurrentUserId() 获取当前登录用户
浏览量初始化为0
5.3 分页查询(Read - List)
java
@GetMapping("/page")
public Result<Page
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size) {
Page
LambdaQueryWrapper
wrapper.orderByDesc(News::getPublishTime); // 按发布时间倒序
return Result.success(newsService.page(pageObj, wrapper));
}
MyBatis-Plus 分页要点:
Page
查询结果自动包含:records(数据)、total(总数)、pages(总页数)等
orderByDesc 保证最新的在前面
返回格式示例:
json
{
"code": 200,
"message": "success",
"data": {
"records": [ ... ],
"total": 50,
"size": 10,
"current": 1,
"pages": 5
}
}
5.4 新闻详情(Read - Detail)
java
@GetMapping("/{id}")
public Result
News news = newsService.getById(id);
if (news == null) return Result.error("新闻不存在");
// 浏览量+1
news.setViewCount(news.getViewCount() + 1);
newsService.updateById(news);
return Result.success(news);
}
要点:
@PathVariable 获取 URL 中的路径参数
查看详情的同时累加浏览量(简单实现)
5.5 修改新闻(Update)+ 权限控制*
java
@PutMapping("/{id}")
public Result<?> updateNews(@PathVariable Integer id, @RequestBody NewsUpdateDTO dto) {
Integer userId = UserContext.getCurrentUserId();
News news = newsService.getById(id);
if (news == null) return Result.error("新闻不存在");
if (!news.getUserId().equals(userId)) return Result.error("只能修改自己的新闻");
news.setTitle(dto.getTitle());
news.setContent(dto.getContent());
newsService.updateById(news);
return Result.success("修改成功");
}
权限控制逻辑:
获取当前用户ID → 查询新闻 → 比较新闻作者ID和当前用户ID
↓
相同 → 允许修改
不同 → 返回错误 "只能修改自己的新闻"
5.6 删除新闻(Delete)
java
@DeleteMapping("/{id}")
public Result<?> deleteNews(@PathVariable Integer id) {
Integer userId = UserContext.getCurrentUserId();
News news = newsService.getById(id);
if (news == null) return Result.error("新闻不存在");
if (!news.getUserId().equals(userId)) return Result.error("只能删除自己的新闻");
newsService.removeById(id);
return Result.success("删除成功");
}
注意: 这里用的是 removeById(物理删除)。如需逻辑删除,可在实体类字段上加 @TableLogic 注解。
六、核心知识点汇总
6.1 注解速查表
| 注解 | 作用 | 位置 |
|---|---|---|
| @RestControllerAdvice | 全局异常处理器 | 类级别 |
| @ExceptionHandler | 指定处理的异常类型 | 方法级别 |
| @Value | 注入配置文件中的值 | 字段级别 |
| @RequestBody | 将请求体JSON转为Java对象 | 参数级别 |
| @PathVariable | 获取URL路径参数 | 参数级别 |
| @RequestParam | 获取查询参数 | 参数级别 |
| @Component | 注册为Spring Bean | 类级别 |
6.2 分层架构
Controller(控制层)
↓ 调用
Service(业务层)
↓ 调用
Mapper(数据访问层)
↓ 操作
Database(数据库)
各层职责:
Controller:接收请求、参数校验、调用Service、返回结果
Service:业务逻辑处理
Mapper:数据库操作(CRUD)
DTO:数据传输对象(前端 ↔ 后端)
Entity:数据库表映射
6.3 认证授权完整链路*
┌─────────────────────────────────────────────────┐
│ 前端 │
│ 1. POST /user/login → 获取token │
│ 2. 保存token到localStorage │
│ 3. 后续请求Header加 Authorization: Bearer
└─────────────────────┬───────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ Spring Boot 后端 │
│ │
│ 请求进入 │
│ ↓ │
│ JwtInterceptor.preHandle() │
│ ├── 提取 Authorization 头 │
│ ├── 去掉 "Bearer " 前缀 │
│ ├── JwtUtil.validateToken() 验证 │
│ ├── JwtUtil.getUserIdFromToken() 提取用户ID │
│ ├── UserContext.setCurrentUserId() 存入 │
│ └── return true → 放行 │
│ ↓ │
│ Controller │
│ └── UserContext.getCurrentUserId() 获取用户 │
│ ↓ │
│ afterCompletion() → UserContext.clear() 清理 │
└─────────────────────────────────────────────────┘
七、Postman 测试流程速查*
7.1 登录获取Token
POST http://localhost:8080/user/login
Body (JSON):
{
"username": "testuser",
"password": "123456"
}
→ 复制返回的 token
7.2 带Token请求*
POST http://localhost:8080/news/add
Headers:
Authorization: Bearer <粘贴token>
Body (JSON):
{
"title": "新闻标题",
"content": "新闻内容"
}
⚠️ 注意: Bearer 和 token 之间有一个空格!
八、常见踩坑点*
| 问题 | 原因 | 解决 |
|---|---|---|
| Token验证失败 | token前没加"Bearer "或多了空格 | 检查前端传值格式 |
| ThreadLocal取到null | 请求没经过拦截器或被排除 | 检查拦截器配置路径 |
| getOne() 报错 | 查询结果不唯一 | 确保查询字段有唯一约束 |
| JJWT报错 NoClassDefFoundError | 缺少 jaxb-api 依赖 | 添加 jaxb-api 依赖 |
| 内存泄漏 | ThreadLocal没清理 | 在 afterCompletion 中 clear() |
| 分页返回空 | MyBatis-Plus 分页插件未配置 | 需配置 MybatisPlusInterceptor |
九、工程目录结构*
com.weitoutiao/
├── common/
│ ├── Result.java ← 统一返回封装
│ └── GlobalExceptionHandler.java ← 全局异常处理
├── config/
│ └── InterceptorConfig.java ← 拦截器配置
├── controller/
│ ├── HelloController.java ← 测试接口
│ ├── UserController.java ← 用户(登录)
│ └── NewsController.java ← 新闻CRUD
├── dto/
│ ├── UserLoginDTO.java ← 登录请求体
│ ├── NewsAddDTO.java ← 新增新闻请求体
│ └── NewsUpdateDTO.java ← 修改新闻请求体
├── entity/
│ ├── User.java ← 用户实体
│ └── News.java ← 新闻实体
├── interceptor/
│ └── JwtInterceptor.java ← JWT拦截器
├── mapper/
│ ├── UserMapper.java ← 用户数据访问
│ └── NewsMapper.java ← 新闻数据访问
├── service/
│ ├── UserService.java ← 用户业务接口
│ ├── NewsService.java ← 新闻业务接口
│ └── impl/
│ ├── UserServiceImpl.java ← 用户业务实现
│ └── NewsServiceImpl.java ← 新闻业务实现
└── util/
├── JwtUtil.java ← JWT工具类
└── UserContext.java ← ThreadLocal用户上下文
浙公网安备 33010602011771号