2276 字
11 分钟
从零搭建AI小程序全栈实战(二·上篇):Spring Boot 基础功能开发
NOTE

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

一、这篇文章将要做的#

1.1 回顾#

上一篇文章中,我们把笔记本GPU推理服务和服务器Spring Boot骨架都跑起来了,网络也打通了。

我们完成了以下目标:

  • 笔记本跑通GPU推理——驱动、CUDA、PyTorch、Flask

  • 服务器跑通Spring Boot——JDK、Demo应用

  • 打通网络——frps、frpc、验证

1.2 这一篇的目标#

  • 集成MySQL + 完整CRUD

  • 图片上传接口

  • 调用笔记本AI推理服务

二、集成 MySQL 与 完整 CRUD#

2.1 服务器端 安装 MySQL#

在服务器上执行:

Terminal window
sudo apt install mysql-server -y
sudo mysql_secure_installation # 按提示设置 root 密码,后面都选 Y

创建数据库和用户:

Terminal window
sudo mysql -u root -p
CREATE DATIF NOT EXISTS mini_app DEFAULT CHARACTER SET utf8mb4;
-- 把 'your_password' 改成自己的强密码,记住它
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON mini_app.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
EXIT;

2.2 本地项目添加依赖#

在你的 pom.xml 里添加依赖(放在 <dependencies> 里现有的 spring-boot-starter-web 旁边):

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

2.3 配置数据库连接#

src/main/resources/application.properties 中加入数据库连接(注意替换密码):

spring.datasource.url=jdbc:mysql://你的IP地址:3306/mini_app?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
spring.datasource.username=appuser
spring.datasource.password=你的密码
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

2.4 创建实体类 ImageRecord#

创建 src/main/java/com/example/demo/entity/ImageRecord.java

package com.example.demo.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "image_records")
public class ImageRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String originalFilename;
@Column(columnDefinition = "TEXT")
private String imageUrl; // 存储路径或base64
private String recognitionResult;
private LocalDateTime createTime;
// 必须有无参构造
public ImageRecord() {}
// 快捷构造
public ImageRecord(String originalFilename, String imageUrl) {
this.originalFilename = originalFilename;
this.imageUrl = imageUrl;
this.createTime = LocalDateTime.now();
}
// Getter / Setter (IDE生成,篇幅限制省略,你全加上)
// 至少需要:getId setId getOriginalFilename setOriginalFilename ...
}

2.5 创建 Repository 和 Controller#

创建 src/main/java/com/example/demo/repository/ImageRecordRepository.java

package com.example.demo.repository;
import com.example.demo.entity.ImageRecord;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ImageRecordRepository extends JpaRepository<ImageRecord, Long> {}

创建 src/main/java/com/example/demo/repository/ImageController.java

package com.example.demo.controller;
import com.example.demo.entity.ImageRecord;
import com.example.demo.repository.ImageRecordRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/images")
public class ImageController {
@Autowired
private ImageRecordRepository repository;
@PostMapping
public ImageRecord create(@RequestBody ImageRecord record) {
return repository.save(record);
}
@GetMapping
public List<ImageRecord> list() {
return repository.findAll();
}
@GetMapping("/{id}")
public ImageRecord getById(@PathVariable Long id) {
return repository.findById(id).orElse(null);
}
@DeleteMapping("/{id}")
public String delete(@PathVariable Long id) {
repository.deleteById(id);
return "deleted";
}
}

2.6 验证#

在服务器通过 curl 测试四个接口,确认数据库读写正常。

Terminal window
# 创建一条记录
curl -X POST http://localhost:8080/api/images \
-H "Content-Type: application/json" \
-d '{"originalFilename":"test.jpg", "imageUrl":"/uploads/test.jpg"}'
# 查询所有
curl http://localhost:8080/api/images

三、添加图片上传接口#

3.1 配置文件上传参数#

application.properties 追加:

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
file.upload-dir=./uploads

3.2 添加文件上传接口#

ImageController 中追加:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
@Value("${file.upload-dir}")
private String uploadDir;
@PostMapping("/upload")
public ImageRecord upload(@RequestParam("file") MultipartFile file) throws IOException {
// 保证目录存在
Files.createDirectories(Paths.get(uploadDir));
// 保存文件
String filename = System.currentTimeMillis() + "_" + file.getOriginalFilename();
File dest = new File(uploadDir + "/" + filename);
file.transferTo(dest);
// 存数据库
ImageRecord record = new ImageRecord(file.getOriginalFilename(), "/uploads/" + filename);
return repository.save(record);
}

3.3 配置静态资源映射(让上传的图片可访问)#

新建 src/main/java/com/example/demo/config/WebConfig.java

package com.example.demo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:./uploads/");
}
}

3.4 验证#

我们使用 curl 模拟上传:

Terminal window
curl -X POST http://localhost:8080/api/images/upload \
-F "file=@/home/ubuntu/一张测试图片.jpg"

返回结果里应该有 imageUrl: "/uploads/xxxx.jpg",浏览器访问 http://服务器IP:8080/uploads/xxxx.jpg 能显示图片,这样就成功了。

四、调用 Windows端 AI推理服务#

4.1 确认 API 可达#

先在服务器上验证穿透仍正常(笔记本端 frpc 要开着):

Terminal window
curl http://localhost:5000/health

返回 {"cuda":true} 就 OK。

4.2 创建调用 Windows端 API 的服务#

创建 src/main/java/com/example/demo/service/AiService.java

package com.example.demo.service;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
@Service
public class AiService {
private final RestTemplate restTemplate = new RestTemplate();
private final String AI_BASE_URL = "http://localhost:5000";
public Map<String, Object> predict(Map<String, Object> input) {
return restTemplate.postForObject(
AI_BASE_URL + "/predict", input, Map.class);
}
public Map<String, Object> health() {
return restTemplate.getForObject(AI_BASE_URL + "/health", Map.class);
}
}

4.3 在 Controller 中串联#

修改 ImageController,注入 AiService 并添加一个“上传即识别”的接口:

@Autowired
private AiService aiService;
@PostMapping("/upload-and-recognize")
public ImageRecord uploadAndRecognize(@RequestParam("file") MultipartFile file) throws IOException {
// 1. 保存文件(复用上面的上传逻辑)
Files.createDirectories(Paths.get(uploadDir));
String filename = System.currentTimeMillis() + "_" + file.getOriginalFilename();
File dest = new File(uploadDir + "/" + filename);
file.transferTo(dest);
// 2. 调用笔记本 AI 推理
Map<String, Object> aiInput = Map.of("imageUrl", "/uploads/" + filename);
Map<String, Object> result = aiService.predict(aiInput);
// 3. 存库
ImageRecord record = new ImageRecord(file.getOriginalFilename(), "/uploads/" + filename);
record.setRecognitionResult(result.toString()); // 实际项目解析具体字段
return repository.save(record);
}

4.4 全链路测试#

我们使用 curl 测试:

Terminal window
curl -X POST http://localhost:8080/api/images/upload-and-recognize \
-F "file=@/home/ubuntu/一张测试图片.jpg"

如果返回记录中包含 recognitionResult 字段(即使是 "fake_prediction" 也算成功),代表 Spring Boot → 笔记本 AI 调用链路已打通

五、打包部署到 Linux#

5.1 打包 JAR#

5.1.1 确认 JAR 文件名#

在打包之前,先看一下项目最终生成的JAR叫什么名字。这个名称由 pom.xml 中的三个标签决定:

<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>

拼接规则:{artifactId}-{version}.jar

所以你的JAR文件名叫:demo-0.0.1-SNAPSHOT.jar

5.1.2 在 IDEA 中打包#

  1. IDEA 右侧边栏点击 Maven(竖排的 “m” 图标)

  2. 展开 demoLifecycle

  3. 先双击 clean(清理上次构建的旧文件,避免混入过期内容)

  4. 等待 BUILD SUCCESS,再双击 package

  5. 控制台出现以下字样即打包成功:

    [INFO] ------------------------------------------------------------------------
    [INFO] BUILD SUCCESS
    [INFO] ------------------------------------------------------------------------

5.1.3 找到打包好的 JAR#

打包完成后,JAR 文件位于项目的 target 目录下:

你的项目目录/
├── src/
├── target/
│ ├── demo-0.0.1-SNAPSHOT.jar ← 这个就是我们要的
│ └── ...
├── pom.xml
└── ...

在 IDEA 中直接查看

IDEA 左侧项目文件树,找到 target 文件夹,展开即可看到 demo-0.0.1-SNAPSHOT.jar

右键点击 JAR 文件 → Open InExplorer(或 Finder),可以直接在文件资源管理器中定位到该文件,方便下一步上传。

5.2 上传到服务器#

TIP

如果服务器上没有 /opt/app 目录,先 SSH 到服务器执行 mkdir -p /opt/app 创建。

在 Powershell (Win + R 输入 powershell)使用 scp 命令上传服务器:

Terminal window
scp target/demo-xxxx.jar terryc@1.2.3.4:/opt/app
  • terryc 此处替换为你的用户名,如 root

  • @1.2.3.4 此处替换为你的服务器外网IP地址,如 @111.111.111.111

  • :/opt/app 此处为你的应用工作目录,你的项目将会在这个目录启动,可替换为其他目录。

5.3 在服务器上启动 Spring Boot 项目#

以文件名 /opt/app/demo-0.0.1-SNAPSHOT.jar 为例。

5.3.1 使用 nohup 启动#

nohup 是最简单的后台启动方式,适合临时测试:

Terminal window
nohup java -jar /opt/app/demo-0.0.1-SNAPSHOT.jar > /opt/app/app.log 2>&1 &
  • nohup:退出SSH后进程不中断

  • > app.log 2>&1:把日志输出到文件

  • &:后台运行

让我们来验证Spring Boot项目是否启动:

Terminal window
tail -f /opt/app/app.log

看到 Started DemoApplication in ... seconds 即启动成功。

但这种方式有明显缺点

  • 重启服务器后不会自动启动,需要手动执行

  • 想停止只能手动 ps -ef | grep java 找到PID然后 kill

  • 更新JAR包时步骤繁琐:停进程 → 换包 → 重启

  • 时间久了容易忘记项目在哪个目录、用的哪个JAR

所以,下面我们换成更规范的方式。

5.3.2 使用 service 启动(推荐)#

5.3.2.0 为什么要推荐 service ?#

对比项nohupsystemd service
开机自启❌ 需要手动设置脚本systemctl enable 一行搞定
启停操作找PID → killsystemctl start/stop/restart
崩溃重启❌ 进程挂了就挂了✅ 配置 Restart=on-failure 自动拉起
日志管理自己用 > 重定向journalctl -u demo -f 统一查看
版本更新手动替换JAR,容易出错配合软链接,换JAR后 systemctl restart 即可
状态查看ps -ef | grep javasystemctl status demo 一眼看清

5.3.2.1 创建 demo.service 文件#

在服务器上创建 service 配置文件:

Terminal window
sudo vim /etc/systemd/system/demo.service

写入以下内容:

[Unit]
Description=Spring Boot Demo Application
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/app
ExecStart=/usr/bin/java -jar /opt/app/demo-current/demo-0.0.1-SNAPSHOT.jar
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target

关键字段解释:

字段含义
After=network.target等网络就绪后再启动,避免数据库连不上
Type=simple标准类型,适用于Java这种前台进程
WorkingDirectory工作目录,上传的文件会存在这里
ExecStart启动命令,指向 /opt/app/demo-current.jar
Restart=on-failure进程崩溃时自动重启
RestartSec=10等10秒再重启,避免频繁重启死循环
WantedBy=multi-user.target多用户模式下自动启动

5.3.2.2 创建 demo-current.jar 软链接#

以后更新JAR包时,新版本文件名会是 demo-0.0.2-SNAPSHOT.jardemo-1.0.0.jar 等。如果 service 文件里写死了具体文件名,每次更新还要改service配置,太麻烦。

用软链接的方式:service 永远指向 demo-current.jar,更新时只需把软链接指向新版本JAR即可

Terminal window
# 创建软链接,指向当前版本的JAR
ln -s /opt/app/demo-0.0.1-SNAPSHOT.jar /opt/app/demo-current.jar

执行后,/opt/app/demo-current.jar 就指向了真实的JAR文件。可以用 ls -l 验证:

Terminal window
ls -l /opt/app/demo-current.jar
# 输出:demo-current.jar -> /opt/app/demo-0.0.1-SNAPSHOT.jar

5.3.2.3 启动并启用服务#

Terminal window
# 重新加载 service 配置
sudo systemctl daemon-reload
# 启动服务
sudo systemctl start demo
# 设置开机自启
sudo systemctl enable demo
# 查看状态
sudo systemctl status demo

看到 active (running) 即成功。

常用管理命令:

Terminal window
sudo systemctl stop demo # 停止
sudo systemctl restart demo # 重启
sudo systemctl status demo # 查看状态
journalctl -u demo -f # 实时查看日志

5.4 验证部署成功#

使用 curl 对项目接口进行测试。

Terminal window
# 服务器本地测试
curl http://localhost:8080/hello
# 应返回:Hello from Server!
curl http://localhost:8080/api/images
# 应返回已创建的图片记录列表
# 浏览器访问
http://你的服务器公网IP:8080/hello
http://你的服务器公网IP:8080/api/images

六、验收清单#

  • GET/POST/DELETE /api/images 四个接口全部正常

  • POST /api/images/upload 能接收图片并返回可访问URL

  • POST /api/images/upload-and-recognize 能调通AI并保存识别结果

从零搭建AI小程序全栈实战(二·上篇):Spring Boot 基础功能开发
https://47.113.107.125:80/posts/ai-miniapp/02a-java后端打底上/
作者
TerryC
发布于
2026-01-02
许可协议
CC BY-NC-SA 4.0