NOTE欢迎来到“从零搭建AI小程序全栈实战”系列文章!
此为本系列的第三篇文章,你可以点击跳转到第一篇文章在系列文章中快速跳转。
一、这篇文章将要做的
1.1 回顾
-
上一篇我们完成了CRUD、图片上传、调用AI推理
-
当前状态:接口能跑,但返回格式参差不齐,出错时直接500页面
1.2 目标
-
让后端”像个正经项目”
-
四个加固项:
-
统一响应格式——前端解析不再猜
-
全局异常处理——错误也返回JSON
-
Swagger接口文档——在线可测试
-
数据校验——非法参数提前拦截
-
二、统一响应格式
2.0 为什么需要统一格式
先看现在的问题,你现在 ImageController 的返回是这样的:
// 返回 ImageRecord 对象@PostMappingpublic ImageRecord create(@RequestBody ImageRecord record) { return repository.save(record);}
// 返回字符串@DeleteMapping("/{id}")public String delete(@PathVariable Long id) { repository.deleteById(id); return "deleted";}前端收到的东西会像这样:
// 调用创建接口可能返回:{"id":1, "originalFilename":"test.jpg", "imageUrl":"/uploads/test.jpg", ...}
// 调用删除接口可能返回:"deleted"问题来了:
-
前端同学不知道返回的到底是成功还是失败,只能猜
-
返回格式不统一,前端要写两套处理逻辑
-
万一出错,Spring Boot 直接扔个 500 页面的 HTML,前端根本没法解析
统一格式后,无论是创建、查询、删除、所有接口都返回这种结构:
{ "code": 200, // 200=成功,400=参数错误,500=服务器错误 "message": "success", // 给人类看的提示 "data": { // 真正的数据放在这里 "id": 1, "originalFilename": "test.jpg" }}前端只需要写一套解析逻辑,看 code 就能知道成败。
2.1 创建 Result 类
在服务器上创建 src/main/java/com/example/demo/common/Result.java:
package com.example.demo.common;
public class Result<T> { private int code; private String message; private T data;
// 无参构造(必须,框架反射用) public Result() {}
// 全参构造 public Result(int code, String message, T data) { this.code = code; this.message = message; this.data = data; }
// ========== 静态工厂方法 ==========
// 成功,带数据 public static <T> Result<T> success(T data) { return new Result<>(200, "success", data); }
// 成功,不带数据(比如删除操作) public static <T> Result<T> success() { return new Result<>(200, "success", null); }
// 失败,自定义状态码和消息 public static <T> Result<T> error(int code, String message) { return new Result<>(code, message, null); }
// ========== Getter / Setter ========== public int getCode() { return code; } public void setCode(int code) { this.code = code; }
public String getMessage() { return message; } public void setMessage(String message) { this.message = message; }
public T getData() { return data; } public void setData(T data) { this.data = data; }}| 代码 | 什么意思 |
|---|---|
Result<T> | 泛型,T 可以是 String、ImageRecord、List,复用同一个类 |
private T data | 实际返回的数据,类型由调用时决定 |
public static <T> Result<T> success(T data) | 静态方法,直接 Result.success(某对象) 就能生成一个成功响应 |
Result.error(400, "参数错误") | 返回错误时用,前端看到 code=400 就知道出问题了 |
2.2 改造 Controller
把 ImageController 每个方法的返回值都包一层 Result。
2.2.1 导入 Result 类
在 ImageController.java 顶部加上:
import com.example.demo.common.Result;2.2.2 新建记录——返回创建好的数据
原来:
@PostMappingpublic ImageRecord create(@RequestBody ImageRecord record) { return repository.save(record);}改后:
@PostMappingpublic Result<ImageRecord> create(@RequestBody ImageRecord record) { ImageRecord saved = repository.save(record); return Result.success(saved);}2.2.3 查询列表——返回数组
原来:
@GetMappingpublic List<ImageRecord> list() { return repository.findAll();}改后:
@GetMappingpublic Result<List<ImageRecord>> list() { List<ImageRecord> records = repository.findAll(); return Result.success(records);}2.2.4 查询单个——不存在时返回错误
原来:
@GetMapping("/{id}")public ImageRecord getById(@PathVariable Long id) { return repository.findById(id).orElse(null);}改后:
@GetMapping("/{id}")public Result<ImageRecord> getById(@PathVariable Long id) { return repository.findById(id) .map(Result::success) .orElse(Result.error(404, "记录不存在"));}2.2.5 删除——返回成功但不带数据
原来:
@DeleteMapping("/{id}")public String delete(@PathVariable Long id) { repository.deleteById(id); return "deleted";}改后:
@DeleteMapping("/{id}")public Result<Void> delete(@PathVariable Long id) { repository.deleteById(id); return Result.success(); // data = null}2.2.6 上传并识别——返回待识别结果的记录
原来:
@PostMapping("/upload-and-recognize")public ImageRecord uploadAndRecognize(@RequestParam("file") MultipartFile file) throws IOException { // ...上传和调用AI... return repository.save(record);}改后:
@PostMapping("/upload-and-recognize")public Result<ImageRecord> uploadAndRecognize(@RequestParam("file") MultipartFile file) throws IOException { // ...上传和调用AI逻辑不变... ImageRecord saved = repository.save(record); return Result.success(saved);}2.3 验证
继续使用 curl 查看效果:
# 创建curl -X POST http://localhost:8080/api/images \ -H "Content-Type: application/json" \ -d '{"originalFilename":"test.jpg", "imageUrl":"/uploads/test.jpg"}'返回会变成:
{ "code": 200, "message": "success", "data": { "id": 1, "originalFilename": "test.jpg", "imageUrl": "/uploads/test.jpg", ... }}当我们尝试用一个不存在的编号查找图片时:
# 查询curl http://localhost:8080/api/images/999此时会返回:
{ "code": 404, "message": "记录不存在", "data": null}三、全局异常处理
3.0 为什么我们需要这个
因为:
-
即使我们写得再好,运行时总有意想不到的异常
-
不能让前端收到HTML格式的500错误页面
现在,让所有未捕获的异常和手动抛出的异常都返回统一格式的 Result。
创建 src/main/java/com/example/demo/exception/GlobalExceptionHandler.java:
package com.example.demo.exception;
import com.example.demo.common.Result;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvicepublic class GlobalExceptionHandler {
@ExceptionHandler(Exception.class) public Result handleException(Exception e) { // 记录日志(实际项目用 log) e.printStackTrace(); return Result.error(500, "服务器内部错误:" + e.getMessage()); }
// 可以针对性捕获,比如参数校验异常}之后再故意制造错误(如请求一个不存在的接口),返回的 JSON 会是:
{"code":500, "message":"服务器内部错误:...", "data":null}四、Springdoc 接口文档
4.0 为什么我们需要这个
现在你的后端有七八个接口了:健康检查、CRUD、上传、识别等等。你自己写的时候可能记得清清楚楚,但过两周再回来看,或者前端同学来问你”这个接口参数是什么、返回什么”,你大概率要翻半天代码才能答上来。
Springdoc 的作用就是自动生成一份在线接口文档,列出所有接口的地址、参数、返回格式,还能直接在页面上测试。省去了手动写文档的麻烦,也方便前后端联调。
4.1 添加依赖
在 pom.xml 的 <dependencies> 中添加 SpringDoc(Spring Boot 3.x 对应的 Swagger 实现):
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.3.0</version></dependency>springdoc-openapi-starter-webmvc-ui 这个依赖已经内置了 Swagger UI,不需要额外引入任何 UI 包。
4.2 配置类(可选)
大多数情况下 springdoc 零配置就能跑,但加上配置类可以让文档页面更专业。
新建 src/main/java/com/example/demo/config/SpringDocConfig.java:
package com.example.demo.config;
import io.swagger.v3.oas.models.OpenAPI;import io.swagger.v3.oas.models.info.Info;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configurationpublic class SpringDocConfig { @Bean public OpenAPI customOpenAPI() { return new OpenAPI() .info(new Info() .title("AI 小程序后端 API") .version("1.0") .description("垃圾分类识别——图像上传、AI推理、记录管理")); }}| 字段 | 含义 |
|---|---|
title | 显示在文档页顶部的标题 |
version | API 版本号 |
description | 对整套接口的简要说明 |
4.3 访问文档
启动项目后,在浏览器打开:
http://localhost:8080/swagger-ui/index.html你会看到一个可直接交互的 API 文档页面,列出了所有 Controller 的接口,还能在线填入参数、发送请求、查看响应,不需要再开 Postman 或 curl。
五、数据校验
5.1 添加依赖
在 pom.xml 的 <dependencies> 中添加 spring-boot-starter-validation
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>5.2 在实体或DTO上加注解
比如限制文件名不能为空:
import jakarta.validation.constraints.NotBlank;
public class ImageRecord { // ... @NotBlank(message = "文件名不能为空") private String originalFilename;}5.3 Controller 参数加 @Valid
@PostMappingpublic Result<ImageRecord> create(@Valid @RequestBody ImageRecord record) { // ...}此时如果请求不带 originalFilename,会抛出 MethodArgumentNotValidException,但会被我们的 GlobalExceptionHandler 捕获并返回 500 错误。
为了返回更友好的格式,可以在 GlobalExceptionHandler 中单独处理:
import org.springframework.web.bind.MethodArgumentNotValidException;
@ExceptionHandler(MethodArgumentNotValidException.class)public Result handleValidation(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); return Result.error(400, msg);}六、重新打包部署
以下演示一次完整的打包部署过程。
6.0 前置条件假设
-
Spring Boot项目根目录位于G:\DEMO_Project\demo。 -
远程 Linux 服务器中
JAVA项目工作目录为/opt/app/。 -
Spring Boot项目中pom.xml中为<version>1.0.0</version>。
6.1 Maven 打包
打开 Intellij IDEA ,选择侧边栏中的 Maven ,选择 执行Maven项目 。
输入 mvn clean package 。
等待出现如下提示:
[INFO] -------------------------------------------[INFO] BUILD SUCCESS[INFO] -------------------------------------------[INFO] Total time: 4.368 s[INFO] Finished at: 2026-05-20T21:39:41+08:00[INFO] -------------------------------------------此时 Spring Boot 项目中出现新文件夹 target 。
在G:\DEMO_Project\demo\target 文件夹下出现两个文件 demo-1.0.0.jar 和 demo-1.0.0.jar.original 和若干文件夹。
注意,我们只需要 demo-1.0.0.jar ,不需要带 original 版本的文件。
6.2 传送文件
我们使用 scp 传送我们打包好的 JAR 文件。
首先,让我们在 Windows 上切换我们的 Powershell 工作目录。
打开 Powershell ,输入以下命令 :
PS C:\Users\TerryC> cd "G:\DEMO_Project\demo"PS G:\DEMO_Project\demo> scp target/demo-1.0.0.jar terryc@1.2.3.4:/opt/app-
terryc此处替换为你在远程服务器的用户名,如root。 -
@1.2.3.4此处替换为你的服务器外网IP地址,如@111.111.111.111。 -
:/opt/app此处为你的应用工作目录,你的项目将会在这个目录启动,可替换为其他目录。
6.3 重建软链接,重启服务
在服务器上关闭 demo 项目:
sudo systemctl stop demo把我们传送到服务器上的 demo-1.0.0.jar 链接到 demo.service 的 demo-current.jar
ln -sf /opt/app/demo-1.0.0.jar /opt/app/demo-current.jar启动并检查 demo 项目的状态:
sudo systemctl start demosudo systemctl status demo出现 Active: active (running) ,则为成功启动。
terryc@i23fFG3edMoPa321Z:~$ sudo systemctl status demo● demo.service - Demo Application Loaded: loaded (/etc/systemd/system/demo.service; enabled; preset: enabled) Active: active (running) since Wed 2026-05-20 22:02:11 CST; 1s ago Main PID: 46722 (java) Tasks: 14 (limit: 1860) Memory: 46.6M (peak: 46.9M) CPU: 1.962s CGroup: /system.slice/demo.service └─46722 /usr/bin/java -Xms512m -Xmx1g -jar /opt/app/demo-current.jar七、验收清单
-
所有接口返回统一的
Result格式(已做) -
出错时返回 JSON 而不会出现 500 页面(全局异常处理)
-
可以访问
doc.html看到接口文档 -
提交非法参数时收到
code: 400和具体错误信息