一、SpringBoot 请求与响应
1.1 环境与依赖
pom.xml 关键依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency> </dependencies>
|
版本注意:Spring Boot 3.x 校验注解包是 jakarta.validation.*,2.x 是 javax.validation.*,其余用法完全一致。
推荐项目结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| src/main/java/com/example/demo/ ├── DemoApplication.java # 启动类(必须在最外层包) ├── controller/ │ └── UserController.java # 接收请求、返回响应 ├── entity/ │ └── User.java # 数据库实体 ├── dto/ │ ├── UserQueryDTO.java # 接收请求参数的对象 │ └── UserCreateDTO.java ├── vo/ │ └── UserVO.java # 返回给前端的对象 └── common/ ├── R.java # 统一响应体 ├── GlobalExceptionHandler.java # 全局异常处理 └── LoginInterceptor.java # 拦截器
|
启动类
1 2 3 4 5 6
| @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }
|
坑:启动类必须放在所有业务包的上层目录(如 com.example.demo),否则 controller、service 扫描不到,接口 404。
1.2 控制器基础
最小可用控制器
1 2 3 4 5 6 7 8 9 10
| @RestController @RequestMapping("/api/users") public class UserController {
@GetMapping("/{id}") public UserVO getById(@PathVariable Long id) { return new UserVO(id, "张三", 25); } }
|
请求映射注解对照表
| 注解 |
等价写法 |
用途 |
@GetMapping |
@RequestMapping(method = GET) |
查询 |
@PostMapping |
@RequestMapping(method = POST) |
新增 |
@PutMapping |
@RequestMapping(method = PUT) |
全量更新 |
@PatchMapping |
@RequestMapping(method = PATCH) |
局部更新 |
@DeleteMapping |
@RequestMapping(method = DELETE) |
删除 |
@RequestMapping |
可指定 path / method / params / headers |
需要精细化匹配时使用 |
精细化映射(限定参数/请求头)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @RestController @RequestMapping("/api/orders") public class OrderController {
@GetMapping(value = "/search", params = "type=app") public List<OrderVO> searchFromApp(@RequestParam String keyword) { return orderService.search(keyword); }
@PostMapping(value = "/create", consumes = "application/json") public R<Long> create(@RequestBody OrderCreateDTO dto) { return R.ok(orderService.create(dto)); }
@GetMapping(value = "/export", produces = "application/json;charset=UTF-8") public R<List<OrderVO>> export() { return R.ok(orderService.listAll()); } }
|
1.3 HttpServletRequest / HttpServletResponse(前置了解)
一句话:SpringMVC 里所有参数注解(@PathVariable、@RequestParam、@RequestBody…)都是 Tomcat 把 HTTP 报文解析成 HttpServletRequest 之后,从里面取值再填到你的方法参数上。看懂原生对象,后面 9 种参数接收方式就不用死记硬背。
1.3.1 一次 HTTP 请求的完整链路
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 浏览器发起 HTTP 请求 ↓ Tomcat 解析报文 → 封装成 HttpServletRequest + HttpServletResponse ↓ Filter 过滤器链(编码 / 跨域 / 全局日志) ↓ DispatcherServlet(前端控制器,统一入口) ↓ HandlerMapping → 根据 URL 找到对应的 Controller 方法 ↓ HandlerAdapter → 参数解析器把 Request 里的数据绑定到方法参数上 ↓ Controller 方法执行(业务处理) ↓ 返回值 → HttpMessageConverter(Jackson)转成 JSON ↓ 写回 HttpServletResponse → Tomcat 组装 HTTP 响应 → 浏览器
|
核心结论:Controller 方法的每个参数,都是由 HandlerMethodArgumentResolver(参数解析器)从 HttpServletRequest 里解析出来并做类型转换的。注解的作用只是告诉它——去哪儿取、怎么转、要不要必填。
1.3.2 HttpServletRequest 常用 API
| 方法 |
作用 |
示例返回值 |
getMethod() |
请求方式 |
GET / POST |
getRequestURI() |
请求路径(不含域名) |
/api/users/1 |
getRequestURL() |
完整 URL |
http://localhost:8080/api/users/1 |
getQueryString() |
URL 上的查询串 |
page=1&size=10 |
getParameter(name) |
取请求参数(URL 或表单) |
1 |
getParameterValues(name) |
取同名多值参数 |
["1","2","3"] |
getParameterMap() |
所有参数 |
{page=["1"], size=["10"]} |
getHeader(name) |
取请求头 |
Bearer xxx |
getHeaderNames() |
所有请求头名 |
枚举 |
getCookies() |
取所有 Cookie |
Cookie[] |
getInputStream() / getReader() |
读请求体(JSON 原始报文) |
只能读一次 |
getContentType() |
请求体类型 |
application/json |
getRemoteAddr() |
客户端 IP |
127.0.0.1 |
getSession() |
获取 Session |
HttpSession |
getAttribute(name) |
取请求域属性(配合拦截器传值) |
setAttribute 存进去的东西 |
getServletContext() |
应用上下文 |
ServletContext |
1.3.3 HttpServletResponse 常用 API
| 方法 |
作用 |
setStatus(int) |
设置状态码(如 401/404) |
setContentType(String) |
设置响应类型 |
setCharacterEncoding(String) |
设置编码 |
setHeader(name, value) |
设置响应头 |
addHeader(name, value) |
追加响应头(可重复) |
getWriter() |
字符输出流(写文本/JSON) |
getOutputStream() |
字节输出流(写文件/图片) |
sendRedirect(url) |
重定向 |
addCookie(Cookie) |
写 Cookie 给浏览器 |
sendError(404) |
直接返回错误页 |
1.3.4 不用任何注解,手写接口
理解了原生对象,下面这段代码就是 SpringMVC 帮你做的事的”展开版”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| @RestController @RequestMapping("/api/raw") public class RawController {
@GetMapping("/user") public void getUser(HttpServletRequest request, HttpServletResponse response) throws IOException {
String idStr = request.getParameter("id");
Long id = Long.valueOf(idStr);
UserVO user = userService.getById(id);
response.setStatus(HttpServletResponse.SC_OK); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSON.toJSONString(R.ok(user))); }
@PostMapping("/create") public void createUser(HttpServletRequest request, HttpServletResponse response) throws IOException {
String body = request.getReader().lines() .collect(Collectors.joining(System.lineSeparator()));
UserCreateDTO dto = JSON.parseObject(body, UserCreateDTO.class); Long id = userService.create(dto);
response.setStatus(HttpServletResponse.SC_CREATED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JSON.toJSONString(R.ok(id))); } }
|
1.3.5 注解 ↔ 原生 API 对照表
| Spring 注解 |
等价于原生写法 |
@RequestParam("id") Long id |
request.getParameter("id") + 类型转换 |
@PathVariable("id") Long id |
从 request.getRequestURI() 里截取路径段 |
@RequestBody UserDTO dto |
request.getReader() 读流 + Jackson 反序列化 |
@RequestHeader("Authorization") |
request.getHeader("Authorization") |
@CookieValue("JSESSIONID") |
遍历 request.getCookies() 找同名 Cookie |
UserQueryDTO query(无注解) |
遍历 request.getParameterMap() 按属性名反射赋值 |
@RequestParam List<Long> ids |
request.getParameterValues("ids") + 逐个转换 |
@RequestParam MultipartFile file |
request.getParts() 取上传的二进制部分 |
@ResponseBody |
response.getWriter().write(JSON.toJSONString(obj)) |
@Valid |
手动 if (dto.getUsername() == null) { ... } |
1.3.6 什么时候必须用原生对象
日常 CRUD 优先用注解,原生对象只在以下场景兜底:
- 文件下载 / 验证码 / 图片:需要直接操作
response.getOutputStream()
- 写 Cookie:
response.addCookie(new Cookie("token", token))
- 拦截器与 Controller 传值:
request.setAttribute("userId", 1L) → Controller 里 request.getAttribute("userId")
- 获取真实 IP:经过 Nginx 代理时要读
X-Forwarded-For 头
- 接口验签:需要读取原始请求体做签名比对(此时不能用
@RequestBody,否则流已被消费)
- 导出 Excel / CSV:直接写输出流,避免大文件撑爆内存
1.3.7 两个必知的坑
1 2 3 4 5 6 7 8
|
response.getWriter().write("xxx");
|
1.4 接收请求参数(9 种常见场景)
建议先看 1.3 HttpServletRequest(前置了解)。下面这些注解本质上都是从原生 Request 里取值,理解底层后下面的写法就不需要死记。
1.4.1 @PathVariable 路径参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @GetMapping("/{id}") public R<UserVO> getById(@PathVariable("id") Long id) { return R.ok(userService.getById(id)); }
@GetMapping("/{id}") public R<UserVO> getById(@PathVariable Long id) { ... }
@GetMapping("/{userId}/orders/{year}") public R<List<OrderVO>> userOrders(@PathVariable Long userId, @PathVariable Integer year) { return R.ok(orderService.listByUserAndYear(userId, year)); }
@GetMapping("/dept/{deptId}/user/{userId}") public R<Map<String, String>> test(@PathVariable Map<String, String> pathVars) { return R.ok(pathVars); }
|
1.4.2 @RequestParam 查询参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| @GetMapping("url") public R<List<UserVO>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { return R.ok(userService.page(page, size)); }
@GetMapping("url") public void list(User user) {
}
@GetMapping("url") public void list(@RequestParam List<String> list) { }
@GetMapping("url") public void list(@RequestParam(name="name") String userName) { }
@GetMapping("/search") public R<List<UserVO>> search(@RequestParam(required = false) String keyword) { return R.ok(userService.search(keyword)); }
@GetMapping("url") public void list(@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") LocalDataTime ldt) { ldt->就是LocalDataTime的日期类型 }
@GetMapping("url") public void list(@RequestBody User user) { }
@GetMapping("/set/{id}/{name}") public void list(@PathVarivable Integer id, @PathVariable ) { }
|
@RequestParam 三个属性:
| 属性 |
说明 |
value/name |
绑定的请求参数名(与变量名一致时可省略) |
required |
是否必传,默认 true,不传抛 400 |
defaultValue |
默认值,设置后 required 自动失效为 false |
1.4.3 @RequestBody JSON 请求体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @Data public class UserCreateDTO { @NotBlank(message = "用户名不能为空") private String username;
@NotBlank(message = "密码不能为空") @Size(min = 6, max = 20, message = "密码长度 6-20 位") private String password;
@Email(message = "邮箱格式不正确") private String email;
private Integer age; }
@PostMapping public R<Long> create(@RequestBody UserCreateDTO dto) { return R.ok(userService.create(dto)); }
|
规则:
- 一个方法只能有一个
@RequestBody
- 前端必须带
Content-Type: application/json,否则报 415
- 接收数组:
@RequestBody List<Long> ids
1 2 3 4 5 6
| @PostMapping("/batch-delete") public R<Void> batchDelete(@RequestBody List<Long> ids) { userService.removeByIds(ids); return R.ok(); }
|
1.4.4 无注解:表单 / 对象自动绑定
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Data public class UserQueryDTO { private String keyword; private Integer status; private Integer page = 1; private Integer size = 10; }
@GetMapping("/list") public R<List<UserVO>> list(UserQueryDTO query) { return R.ok(userService.list(query)); }
|
Spring 会把请求参数按属性名自动塞进对象。这是最省事的写法,查询类接口优先用它。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @GetMapping("/header") public R<String> header(@RequestHeader("Authorization") String token) { return R.ok(token); }
@GetMapping("/ua") public R<String> ua(@RequestHeader(value = "User-Agent", defaultValue = "unknown") String ua) { return R.ok(ua); }
@GetMapping("/all-headers") public R<Map<String, String>> allHeaders(@RequestHeader Map<String, String> headers) { return R.ok(headers); }
@GetMapping("/cookie") public R<String> cookie(@CookieValue(value = "JSESSIONID", required = false) String jsessionId) { return R.ok(jsessionId); }
|
1.4.6 数组 / 集合参数
1 2 3 4 5 6 7 8 9 10 11
| @GetMapping("/ids") public R<List<UserVO>> byIds(@RequestParam List<Long> ids) { return R.ok(userService.listByIds(ids)); }
@GetMapping("/ids2") public R<List<UserVO>> byIds2(@RequestParam Long[] ids) { return R.ok(userService.listByIds(Arrays.asList(ids))); }
|
1.4.7 日期时间参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Data public class ReportQueryDTO { @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime endDate; }
@GetMapping("/report") public R<List<ReportVO>> report(ReportQueryDTO query) { return R.ok(reportService.list(query)); }
|
全局配置(免得每个字段都写注解):
1 2 3 4 5
| spring: mvc: format: date: yyyy-MM-dd date-time: yyyy-MM-dd HH:mm:ss
|
1.4.8 文件上传 MultipartFile
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| @RestController @RequestMapping("/api/files") public class FileController {
@PostMapping("/upload") public R<String> upload(@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()) { return R.fail("文件为空"); }
String originalName = file.getOriginalFilename(); long size = file.getSize(); String ext = originalName.substring(originalName.lastIndexOf(".")); String newName = UUID.randomUUID() + ext;
Path target = Paths.get("D:/uploads/" + newName); Files.createDirectories(target.getParent()); file.transferTo(target);
return R.ok("/files/" + newName); }
@PostMapping("/upload-batch") public R<List<String>> uploadBatch(@RequestParam("files") List<MultipartFile> files, @RequestParam String bizType) throws IOException { List<String> urls = new ArrayList<>(); for (MultipartFile f : files) { String url = storageService.save(f, bizType); urls.add(url); } return R.ok(urls); } }
|
上传大小限制:
1 2 3 4 5
| spring: servlet: multipart: max-file-size: 10MB max-request-size: 100MB
|
1.4.9 原生 HttpServletRequest / HttpServletResponse
完整 API 说明见 1.3,这里只列最常用的取法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| @GetMapping("/raw") public void raw(HttpServletRequest request, HttpServletResponse response) throws IOException { String method = request.getMethod(); String uri = request.getRequestURI(); String ip = request.getRemoteAddr(); String ua = request.getHeader("User-Agent"); String token = request.getParameter("token");
response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":0,\"msg\":\"ok\"}"); }
public static String getIpAddr(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip != null && ip.contains(",") ? ip.split(",")[0].trim() : ip; }
|
参数接收方式速查表
| 场景 |
写法 |
前端传参方式 |
/users/1 |
@PathVariable Long id |
URL 路径 |
?page=1 |
@RequestParam Integer page |
URL 查询串 / 表单 |
{"name":"x"} |
@RequestBody XxxDTO dto |
JSON 请求体 |
?a=1&b=2 多参数 |
XxxDTO dto(无注解) |
查询串 / 表单自动绑定 |
| 请求头 |
@RequestHeader("Authorization") |
Header |
| Cookie |
@CookieValue("JSESSIONID") |
Cookie |
| 逗号分隔 / 重复 key |
@RequestParam List<Long> ids |
ids=1,2,3 |
| 日期 |
@DateTimeFormat(pattern=...) |
2026-10-15 |
| 文件 |
@RequestParam MultipartFile file |
multipart/form-data |
| 拿原生对象 |
HttpServletRequest request |
— |
1.5 参数校验 @Valid
常用校验注解
| 注解 |
作用 |
适用类型 |
@NotNull |
不能为 null |
任意 |
@NotEmpty |
不能为 null 且长度 > 0 |
字符串/集合 |
@NotBlank |
不能为 null 且去空格后长度 > 0 |
字符串 |
@Size |
长度范围 |
字符串/集合 |
@Min / @Max |
数值范围 |
数字 |
@Pattern |
正则 |
字符串 |
@Email |
邮箱格式 |
字符串 |
@Past / @Future |
时间必须过去 / 将来 |
日期 |
@Valid |
嵌套对象级联校验 |
对象 |
使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| @Data public class UserCreateDTO {
@NotBlank(message = "用户名不能为空") @Size(min = 2, max = 20, message = "用户名长度 2-20") private String username;
@NotBlank(message = "密码不能为空") @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d]{6,20}$", message = "密码需 6-20 位且包含字母和数字") private String password;
@NotNull(message = "年龄不能为空") @Min(value = 1, message = "年龄最小 1 岁") @Max(value = 150, message = "年龄最大 150 岁") private Integer age;
@NotEmpty(message = "角色不能为空") private List<Long> roleIds;
@Valid @NotNull(message = "地址不能为空") private AddressDTO address; }
@Data public class AddressDTO { @NotBlank(message = "城市不能为空") private String city; }
@PostMapping public R<Long> create(@Valid @RequestBody UserCreateDTO dto) { return R.ok(userService.create(dto)); }
@GetMapping("/list") public R<List<UserVO>> list(@Validated UserQueryDTO query) { ... }
|
分组校验(新增/编辑用同一 DTO)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| public class ValidationGroups { public interface Create {} public interface Update {} }
@Data public class UserDTO { @Null(groups = Create.class, message = "新增时 id 必须为空") @NotNull(groups = Update.class, message = "编辑时 id 不能为空") private Long id;
@NotBlank(groups = {Create.class, Update.class}, message = "用户名不能为空") private String username; }
@PostMapping public R<Long> create(@Validated(ValidationGroups.Create.class) @RequestBody UserDTO dto) { ... }
@PutMapping public R<Void> update(@Validated(ValidationGroups.Update.class) @RequestBody UserDTO dto) { ... }
|
1.6 响应数据
1.6.1 直接返回对象(自动转 JSON)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Data @AllArgsConstructor public class UserVO { private Long id; private String name; private Integer age;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime; }
@GetMapping("/{id}") public UserVO getById(@PathVariable Long id) { return userService.getById(id); }
|
1.6.2 ResponseEntity(控制状态码和响应头)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @PostMapping public ResponseEntity<UserVO> create(@RequestBody UserCreateDTO dto) { UserVO vo = userService.create(dto); return ResponseEntity.status(HttpStatus.CREATED).body(vo); }
@GetMapping("/download/{fileName}") public ResponseEntity<byte[]> download(@PathVariable String fileName) throws IOException { Path path = Paths.get("D:/uploads/" + fileName); byte[] data = Files.readAllBytes(path);
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); headers.setContentDispositionFormData("attachment", URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()));
return new ResponseEntity<>(data, headers, HttpStatus.OK); }
@DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { userService.removeById(id); return ResponseEntity.noContent().build(); }
|
1.6.3 统一响应体(项目必配)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @Data @NoArgsConstructor @AllArgsConstructor public class R<T> implements Serializable { private Integer code; private String message; private T data; private Long timestamp = System.currentTimeMillis();
public static <T> R<T> ok() { return new R<>(0, "success", null, System.currentTimeMillis()); }
public static <T> R<T> ok(T data) { return new R<>(0, "success", data, System.currentTimeMillis()); }
public static <T> R<T> fail(String message) { return new R<>(500, message, null, System.currentTimeMillis()); }
public static <T> R<T> fail(Integer code, String message) { return new R<>(code, message, null, System.currentTimeMillis()); } }
|
使用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @RestController @RequestMapping("/api/users") public class UserController {
@GetMapping("/{id}") public R<UserVO> getById(@PathVariable Long id) { return R.ok(userService.getById(id)); }
@GetMapping("/page") public R<PageResult<UserVO>> page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { return R.ok(userService.page(page, size)); }
@PostMapping public R<Long> create(@Valid @RequestBody UserCreateDTO dto) { return R.ok(userService.create(dto)); }
@DeleteMapping("/{id}") public R<Void> delete(@PathVariable Long id) { userService.removeById(id); return R.ok(); } }
|
前端拿到的统一结构:
1 2 3 4 5 6
| { "code": 0, "message": "success", "data": { "id": 1, "name": "张三", "age": 25 }, "timestamp": 1760600000000 }
|
1.6.4 响应包装的偷懒方案:ResponseBodyAdvice
不想每个方法都手写 R.ok(...),用这个自动包装:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @RestControllerAdvice(basePackages = "com.example.demo.controller") public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
@Override public boolean supports(MethodParameter returnType, Class<?> converterType) { return !returnType.getParameterType().isAssignableFrom(R.class) && !returnType.getParameterType().isAssignableFrom(ResponseEntity.class); }
@Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<?> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { if (body instanceof String) { try { response.getHeaders().setContentType(MediaType.APPLICATION_JSON); return new ObjectMapper().writeValueAsString(R.ok(body)); } catch (JsonProcessingException e) { return R.fail(e.getMessage()); } } return R.ok(body); } }
|
这样 Controller 可以直接返回业务对象:
1 2 3 4
| @GetMapping("/{id}") public UserVO getById(@PathVariable Long id) { return userService.getById(id); }
|
1.7 全局异常处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| @RestControllerAdvice @Slf4j public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class) public R<Void> handleValidException(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + ": " + f.getDefaultMessage()) .collect(Collectors.joining(", ")); log.warn("参数校验失败: {}", msg); return R.fail(400, msg); }
@ExceptionHandler(ConstraintViolationException.class) public R<Void> handleConstraintViolation(ConstraintViolationException e) { String msg = e.getConstraintViolations().stream() .map(ConstraintViolation::getMessage) .collect(Collectors.joining(", ")); return R.fail(400, msg); }
@ExceptionHandler(MethodArgumentTypeMismatchException.class) public R<Void> handleTypeMismatch(MethodArgumentTypeMismatchException e) { return R.fail(400, "参数 " + e.getName() + " 类型错误"); }
@ExceptionHandler(HttpRequestMethodNotSupportedException.class) public R<Void> handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { return R.fail(405, "不支持的请求方法: " + e.getMethod()); }
@ExceptionHandler(HttpMessageNotReadableException.class) public R<Void> handleNotReadable(HttpMessageNotReadableException e) { return R.fail(400, "请求体 JSON 格式错误"); }
@ExceptionHandler(MissingServletRequestParameterException.class) public R<Void> handleMissingParam(MissingServletRequestParameterException e) { return R.fail(400, "缺少必填参数: " + e.getParameterName()); }
@ExceptionHandler(MaxUploadSizeExceededException.class) public R<Void> handleMaxUpload(MaxUploadSizeExceededException e) { return R.fail(400, "文件大小超出限制"); }
@ExceptionHandler(BizException.class) public R<Void> handleBizException(BizException e) { log.error("业务异常: ", e); return R.fail(e.getCode(), e.getMessage()); }
@ExceptionHandler(Exception.class) public R<Void> handleException(Exception e) { log.error("系统异常: ", e); return R.fail(500, "服务器开小差了,请稍后重试"); } }
|
自定义业务异常:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Data @EqualsAndHashCode(callSuper = true) public class BizException extends RuntimeException { private Integer code;
public BizException(String message) { super(message); this.code = 500; }
public BizException(Integer code, String message) { super(message); this.code = code; } }
if (user == null) { throw new BizException(404, "用户不存在"); }
|
1.8 拦截器与过滤器
拦截器 HandlerInterceptor(最常用:登录校验)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| @Component public class LoginInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !TokenUtil.verify(token)) { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; } request.setAttribute("userId", TokenUtil.getUserId(token)); return true; }
@Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { }
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { } }
|
注册拦截器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Configuration public class WebConfig implements WebMvcConfigurer {
@Autowired private LoginInterceptor loginInterceptor;
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/files/**", "/error" ); } }
|
过滤器 Filter(更底层,处理编码、跨域日志)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @Component @Order(1) public class LogFilter implements Filter {
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; long start = System.currentTimeMillis(); try { chain.doFilter(req, res); } finally { long cost = System.currentTimeMillis() - start; System.out.printf("%s %s 耗时 %dms%n", request.getMethod(), request.getRequestURI(), cost); } } }
|
拦截器 vs 过滤器
| 对比项 |
Filter |
Interceptor |
| 归属 |
Servlet 规范 |
SpringMVC |
| 生效范围 |
所有请求(含静态资源) |
只拦截 Controller |
| 拿到的对象 |
原生 Request/Response |
还能拿到 Handler 方法信息 |
| 依赖注入 |
支持(注册为 Bean) |
支持 |
| 典型用途 |
编码、跨域、全局日志、XSS 过滤 |
登录鉴权、权限、接口耗时统计 |
1.9 跨域 CORS
方案一:配置类(推荐)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @Configuration public class CorsConfig implements WebMvcConfigurer {
@Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") .allowedHeaders("*") .exposedHeaders("Authorization", "Content-Disposition") .allowCredentials(true) .maxAge(3600); } }
|
方案二:单接口注解
1 2 3
| @CrossOrigin(origins = "http://localhost:5173", allowCredentials = "true") @GetMapping("/{id}") public R<UserVO> getById(@PathVariable Long id) { ... }
|
坑:allowCredentials(true) 时 allowedOrigins **不能写 "*"**,要用 allowedOriginPatterns("*")。
1.10 Jackson 常用注解(控制 JSON 字段)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @Data public class UserVO {
@JsonProperty("user_name") private String userName;
@JsonIgnore private String password;
@JsonInclude(JsonInclude.Include.NON_NULL) private String remark;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;
@JsonSerialize(using = BigDecimalSerializer.class) private BigDecimal amount;
@JsonFormat(shape = JsonFormat.Shape.OBJECT) private StatusEnum status; }
|
全局配置:
1 2 3 4 5 6 7
| spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 default-property-inclusion: non_null deserialization: fail-on-unknown-properties: false
|
1.11 常见坑与排查
| 现象 |
原因 |
解决 |
| 接口 404 |
① 启动类包路径太深 ② 路径拼写错 ③ 没加 @RestController |
启动类放最外层包;检查 @RequestMapping 拼接结果 |
@RequestBody 报 415 |
前端没带 Content-Type: application/json |
axios 设置 headers: {'Content-Type':'application/json'} |
| 日期参数 400 |
字符串无法转 LocalDate |
加 @DateTimeFormat 或全局 spring.mvc.format |
| Long 传到前端精度丢失 |
JS 数字最大安全值 2^53-1,id 太长 |
全局把 Long 序列化为 String,或字段改 String |
| 中文乱码 |
响应未指定 UTF-8 |
produces = "application/json;charset=UTF-8" |
| 参数接收到 null |
① 名字不一致 ② 用了 @RequestBody 但传的是表单 |
核对参数名;表单提交去掉 @RequestBody |
| OPTIONS 预检失败 |
跨域未放行 OPTIONS |
CORS 配置里加上 OPTIONS 方法 |
@Valid 不生效 |
忘了加 @Valid / 嵌套对象没加 |
@Valid @RequestBody;嵌套字段加 @Valid |
| 拦截器不生效 |
没注册到 WebMvcConfigurer,或被 excludePathPatterns 排除了 |
检查注册代码和路径匹配规则 |
Long 精度丢失全局处理
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Configuration public class JacksonConfig {
@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); builder.serializerByType(BigInteger.class, ToStringSerializer.instance); }; } }
|
1.12 完整示例:用户模块接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
| @RestController @RequestMapping("/api/users") @Validated public class UserController {
@Autowired private UserService userService;
@GetMapping public R<PageResult<UserVO>> page(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { return R.ok(userService.page(page, size, keyword)); }
@GetMapping("/{id}") public R<UserVO> getById(@PathVariable Long id) { UserVO vo = userService.getById(id); if (vo == null) { throw new BizException(404, "用户不存在"); } return R.ok(vo); }
@PostMapping public R<Long> create(@Valid @RequestBody UserCreateDTO dto) { return R.ok(userService.create(dto)); }
@PutMapping("/{id}") public R<Void> update(@PathVariable Long id, @Valid @RequestBody UserUpdateDTO dto) { userService.update(id, dto); return R.ok(); }
@PatchMapping("/{id}/status") public R<Void> updateStatus(@PathVariable Long id, @RequestParam Integer status) { userService.updateStatus(id, status); return R.ok(); }
@DeleteMapping("/{id}") public R<Void> delete(@PathVariable Long id) { userService.removeById(id); return R.ok(); }
@PostMapping("/batch-delete") public R<Void> batchDelete(@RequestBody List<Long> ids) { userService.removeByIds(ids); return R.ok(); }
@GetMapping("/export") public void export(HttpServletResponse response) throws IOException { List<UserVO> list = userService.listAll(); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode("用户列表.xlsx", "UTF-8")); userService.exportExcel(list, response.getOutputStream()); } }
|
配套 DTO / VO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| @Data public class UserCreateDTO { @NotBlank(message = "用户名不能为空") private String username;
@NotBlank(message = "密码不能为空") @Size(min = 6, max = 20, message = "密码 6-20 位") private String password;
@Email(message = "邮箱格式不正确") private String email;
@NotNull(message = "年龄不能为空") @Min(1) @Max(150) private Integer age; }
@Data public class UserVO { private String id; private String username; private String email; private Integer age; private String statusDesc;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime; }
@Data @AllArgsConstructor public class PageResult<T> { private List<T> records; private Long total; private Integer page; private Integer size;
public static <T> PageResult<T> of(List<T> records, Long total, Integer page, Integer size) { return new PageResult<>(records, total, page, size); } }
|