2286 字
11 分钟
从零搭建AI小程序全栈实战(二·下篇):Java 后端工程化加固
NOTE

欢迎来到“从零搭建AI小程序全栈实战”系列文章!
此为本系列的第三篇文章,你可以点击跳转到第一篇文章在系列文章中快速跳转。

一、这篇文章将要做的#

1.1 回顾#

  • 上一篇我们完成了CRUD、图片上传、调用AI推理

  • 当前状态:接口能跑,但返回格式参差不齐,出错时直接500页面

1.2 目标#

  • 让后端”像个正经项目”

  • 四个加固项:

    1. 统一响应格式——前端解析不再猜

    2. 全局异常处理——错误也返回JSON

    3. Swagger接口文档——在线可测试

    4. 数据校验——非法参数提前拦截

二、统一响应格式#

2.0 为什么需要统一格式#

先看现在的问题,你现在 ImageController 的返回是这样的:

// 返回 ImageRecord 对象
@PostMapping
public 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 新建记录——返回创建好的数据#

原来:

@PostMapping
public ImageRecord create(@RequestBody ImageRecord record) {
return repository.save(record);
}

改后:

@PostMapping
public Result<ImageRecord> create(@RequestBody ImageRecord record) {
ImageRecord saved = repository.save(record);
return Result.success(saved);
}

2.2.3 查询列表——返回数组#

原来:

@GetMapping
public List<ImageRecord> list() {
return repository.findAll();
}

改后:

@GetMapping
public 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 查看效果:

Terminal window
# 创建
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",
...
}
}

当我们尝试用一个不存在的编号查找图片时:

Terminal window
# 查询
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;
@RestControllerAdvice
public 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;
@Configuration
public class SpringDocConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("AI 小程序后端 API")
.version("1.0")
.description("垃圾分类识别——图像上传、AI推理、记录管理"));
}
}
字段含义
title显示在文档页顶部的标题
versionAPI 版本号
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#

@PostMapping
public 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.jardemo-1.0.0.jar.original 和若干文件夹。

注意,我们只需要 demo-1.0.0.jar ,不需要带 original 版本的文件。

6.2 传送文件#

我们使用 scp 传送我们打包好的 JAR 文件。

首先,让我们在 Windows 上切换我们的 Powershell 工作目录。

打开 Powershell ,输入以下命令 :

Terminal window
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 项目:

Terminal window
sudo systemctl stop demo

把我们传送到服务器上的 demo-1.0.0.jar 链接到 demo.servicedemo-current.jar

Terminal window
ln -sf /opt/app/demo-1.0.0.jar /opt/app/demo-current.jar

启动并检查 demo 项目的状态:

Terminal window
sudo systemctl start demo
sudo systemctl status demo

出现 Active: active (running) ,则为成功启动。

Terminal window
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 和具体错误信息

从零搭建AI小程序全栈实战(二·下篇):Java 后端工程化加固
https://47.113.107.125:80/posts/ai-miniapp/02b-java后端打底下/
作者
TerryC
发布于
2026-01-03
许可协议
CC BY-NC-SA 4.0