**摘要:**本文以「新增员工」为例,完整梳理一次带登录校验的后端请求从 nginx 反向代理、SpringMVC 分发、JWT 拦截器验令牌,到 Controller 接收 JSON、Service 执行业务并构造 Entity、Mapper 写入 MySQL,再到结果逐层返回并序列化成 JSON 回前端的全过程;同时说明异常如何统一抛给 GlobalExceptionHandler 转成 code=0 的 Result,以及 ThreadLocal 在跨层传递当前登录人时的作用。
一、完整流程(以"新增员工"为例)
第 0 步:先有登录,才谈得上后续
POST /api/employee/login → nginx(80) → /admin/employee/login → 8080
这个 URL 在 WebMvcConfiguration 里被 excludePathPatterns("/admin/employee/login") 排除了,所以它是唯一不需要令牌就能进的接口。
进到 EmployeeController.login() 后:
- 调
employeeService.login()查库、比对 MD5 密码 - 验证通过 →
JwtUtil.createJWT()生成令牌,把员工 id 塞进载荷 - 装进
EmployeeLoginVO(含 id、用户名、姓名、token)→Result.success(vo)返回
前端拿到后把 token 存起来,之后每个请求都放在请求头 token 里带上。
第 1 步:nginx 反向代理
前端发 POST /api/employee,nginx 按配置 location /api/ → proxy_pass http://localhost:8080/admin/,把路径重写成 /admin/employee 转给后端。这一步是路径前缀的替换,前端代码里写 /api/...,后端 Controller 写的是 /admin/...。
第 2 步:SpringMVC 分发 + JWT 拦截器
请求先进 DispatcherServlet,它在调用 Controller 之前 先过 JwtTokenAdminInterceptor.preHandle():
java
String token = request.getHeader(jwtProperties.getAdminTokenName()); // 取请求头
Claims claims = JwtUtil.parseJWT(...); // 解析令牌
Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID)...);
BaseContext.setCurrentId(empId); // ← 存进 ThreadLocal
return true; // 放行
这里有两件事:
- 令牌有效 → 放行进 Controller
- 令牌无效/过期 →
response.setStatus(401); return false;------ 请求直接终止,压根进不了 Controller,前端收到 401 后跳回登录页 - 顺带把当前员工的 id 存进了 ThreadLocal (
BaseContext),这点下面会用到
第 3 步:Controller
java
@PostMapping
public Result save(@RequestBody EmployeeDTO employeeDTO) {
employeeService.save(employeeDTO);
return Result.success();
}
@RequestBody 让 SpringMVC 用 Jackson 把请求体的 JSON 自动反序列化成 EmployeeDTO 对象。注意分页接口不加这个注解,因为参数是 URL query 形式(?page=1&pageSize=10),SpringMVC 会直接按属性名绑定。
第 4 步:Service ------ 业务逻辑 + 造 Entity
java
Employee employee = new Employee();
BeanUtils.copyProperties(employeeDTO, employee); // DTO → Entity 属性拷贝
employee.setStatus(StatusConstant.ENABLE); // 默认启用
employee.setPassword(md5("123456")); // 默认密码加密
employee.setCreateTime(LocalDateTime.now());
employee.setCreateUser(BaseContext.getCurrentId()); // ← 从 ThreadLocal 取出!
employee.setUpdateUser(BaseContext.getCurrentId());
employeeMapper.insert(employee);
这就是 ThreadLocal 的作用 :Controller 和 Service 的方法签名里都没有"当前登录人"这个参数,但新增员工要记录"谁创建的"。拦截器已经在第 2 步把 empId 存进了当前线程的 ThreadLocal,Service 直接取就行。因为一次请求从头到尾都在同一个线程里处理,所以能透传过来。
第 5 步:Mapper → MySQL
你的 EmployeeMapper 现在用的是注解方式:
java
@Insert("insert into employee(...) values(...)")
void insert(Employee employee);
MyBatis 拿到接口方法 → 执行 SQL → 写库。
第 6 步:返回
走回头路,逐层往上:
Mapper 返回 → Service 返回 → Controller 拿到结果
↓
Result.success() / Result.success(vo)
↓
SpringMVC 消息转换器把 Result 对象序列化成 JSON
↓
HTTP 响应 → nginx → 浏览器
最后那步"对象变 JSON"是 @RestController 干的活,靠 Jackson 自动完成,不用写代码。
二、异常处理的分支
Service 里发现不合规就抛异常,不自己 try-catch:
java
if (employee == null) throw new AccountNotFoundException("账号不存在");
if (!password.equals(...)) throw new PasswordErrorException("密码错误");
if (employee.getStatus() == DISABLE) throw new AccountLockedException("账号被锁定");
这些异常类都继承 BaseException,往上抛,穿过 Controller,最后被 GlobalExceptionHandler 拦下:
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler
public Result exceptionHandler(BaseException ex) {
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage()); // code=0, msg="密码错误"
}
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex) {
// 用户名重复:从 "Duplicate entry 'zhangsan' for key ..." 里抠出 zhangsan
return Result.error(userName + "已存在");
}
}
所以响应状态码始终是 200 ,靠 code 字段区分成败(0=失败)。前端 axios 拿到后判断 code === 0 就弹出 msg。
这样设计的好处:Service 里不用写一堆 if...else return 错误信息,业务逻辑清爽,错误处理集中在一处。
一句话串起来: nginx 转发 → 拦截器验令牌(顺便记下是谁)→ Controller 收 JSON 转成 DTO → Service 跑业务(出问题就抛异常)→ Mapper 操作数据库 → 返回时 Entity 转 VO 再套 Result → 序列化成 JSON 回前端;中途抛的异常被 GlobalExceptionHandler 统一转成
code=0的 Result。
