1. 创建项目目录
首先创建项目的顶层目录:
mkdir ai-chat
cd ai-chat2. 通过 Spring Initializr 创建项目
打开 start.spring.io,按以下配置创建项目:
- Project:Maven
- Language:Java
- Spring Boot:4.x
- Group:com.albertstack
- Artifact:ai-chat-backend
- Package name:com.albertstack.aichat
- Packaging:Jar
- Configuration:YAML
- Java:25
- Dependencies:Lombok、Spring Reactive Web、Spring Data R2DBC、H2 Database
点击 Generate 下载压缩包,解压到 ai-chat/ 目录下,确保目录结构为:
ai-chat/
└── ai-chat-backend/
├── pom.xml
├── src/
└── ...3. 理解已引入的依赖
打开 pom.xml,Spring Initializr 已经帮我们引入了以下依赖:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-h2console</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.r2dbc</groupId>
<artifactId>r2dbc-h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>以下是各项技术的选型理由:
- WebFlux(舍弃传统Spring MVC):AI对话需要用到 SSE 流式响应,这项功能在 WebFlux 里是原生支持的,用 Flux 就能轻松实现打字机效果,比起传统 MVC,实现起来更顺手、更自然。
- R2DBC(舍弃JPA/JDBC):WebFlux 本身是非阻塞框架,如果搭配阻塞式的 JDBC,会拖慢整体运行效率。R2DBC 支持全程非阻塞的数据库访问,和WebFlux搭配使用,适配度拉满。
- H2:这款嵌入式数据库不用额外配置,开箱就能运行,很适合做专栏演示和本地开发,后期也能无缝替换成 PostgreSQL。
- Lombok:能自动生成getter、setter、构造器这类重复样板代码,省去手写冗余代码的麻烦,让代码更精简。
- spring-boot-starter-data-r2dbc-test、spring-boot-starter-webflux-test:这是 Spring Boot 4拆分后的专用测试套件,内置了 StepVerifier、WebTestClient工具,分别用来测试响应式流和 WebFlux 接口,能满足项目的测试需求。
4. 手动添加 Spring AI 依赖
Spring AI 2.x 基于 Spring Boot 4 构建,starter 命名已更新为 spring-ai-starter-model-{provider} 格式。在 pom.xml 的 <dependencies> 中添加:
<!-- Spring AI Ollama -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>Spring AI 目前还不在 Spring Boot 的父 POM 中管理版本,需要手动添加 BOM。在 pom.xml 的 <dependencyManagement> 中添加:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.0-M3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>由于 Spring AI 2.0 仍处于里程碑阶段,还需要添加 Spring 里程碑仓库:
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>Spring AI 正式 GA 后,这个仓库配置就可以去掉了,依赖会发布到 Maven Central。
5. 基础配置
Spring Initializr 创建项目时已经选择了 YAML 格式,打开 src/main/resources/application.yaml,写入以下配置:
spring:
application:
name: ai-chat-backend
# R2DBC 数据库配置
r2dbc:
url: r2dbc:h2:file:///./data/ai-chat?options=AUTO_SERVER=TRUE
username: sa
password:
# SQL 初始化
sql:
init:
mode: always
schema-locations: classpath:schema.sql
# Spring AI Ollama 配置
ai:
ollama:
base-url: http://localhost:11434
chat:
model: qwen3.5:9b
server:
port: 8080配置说明:
r2dbc.url:H2 使用文件模式持久化,数据保存在项目根目录的data/文件夹sql.init:每次启动自动执行schema.sql建表ai.ollama:指向本地 Ollama 服务,使用 qwen3.5:9b 模型
6. 创建数据库 Schema
暂时只建一个用户表,后续章节会逐步添加更多表。
src/main/resources/schema.sql
CREATE TABLE IF NOT EXISTS USERS (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);7. 定义统一响应对象
在编写业务接口之前,先定义一套统一的 API 响应格式。这样所有接口的返回结构一致,前端也能统一处理。
7.1 响应码枚举
src/main/java/com/albertstack/aichat/common/ResultCode.java
package com.albertstack.aichat.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResultCode {
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未授权"),
FORBIDDEN(403, "禁止访问"),
NOT_FOUND(404, "资源不存在"),
INTERNAL_ERROR(500, "服务器内部错误");
private final int code;
private final String message;
}这里用 Lombok 的 @Getter 和 @AllArgsConstructor 自动生成 getter 和构造器,省去样板代码。
7.2 统一响应包装
src/main/java/com/albertstack/aichat/common/ApiResponse.java
package com.albertstack.aichat.common;
public record ApiResponse<T>(int code, String message, T data) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}
public static <T> ApiResponse<T> success() {
return success(null);
}
public static <T> ApiResponse<T> error(ResultCode resultCode) {
return new ApiResponse<>(resultCode.getCode(), resultCode.getMessage(), null);
}
public static <T> ApiResponse<T> error(ResultCode resultCode, String message) {
return new ApiResponse<>(resultCode.getCode(), message, null);
}
}所有接口的 JSON 响应格式统一为:
{
"code": 200,
"message": "操作成功",
"data": { }
}code:业务状态码message:状态描述data:返回数据
成功时调用 ApiResponse.success(data),失败时调用 ApiResponse.error(ResultCode.BAD_REQUEST, "具体原因")。
8. 编写心跳接口
写一个最简单的接口来验证项目能跑起来,同时验证统一响应格式。心跳接口(Ping/Pong)是最常见的服务存活检测方式。
src/main/java/com/albertstack/aichat/controller/PingController.java
package com.albertstack.aichat.controller;
import com.albertstack.aichat.common.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@RestController
public class PingController {
@GetMapping("/api/ping")
public Mono<ApiResponse<String>> ping() {
return Mono.just(ApiResponse.success("pong"));
}
}9. 编写接口测试工具
在写测试之前,先创建一个测试工具类。默认的测试只有"通过"或"失败",看不到接口实际返回了什么。我们希望测试运行时能清晰地看到:
- 请求了哪个接口
- 响应的完整 JSON
- 每个字段的校验结果(✅ 通过 / ❌ 失败)
我们把 WebTestClient、请求构建、响应日志、字段校验封装到一个 ApiTest 类中,通过链式调用 + 函数式校验让测试代码更简洁。
src/test/java/com/albertstack/aichat/util/ApiTest.java
package com.albertstack.aichat.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.test.web.reactive.server.WebTestClient;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.fail;
/**
* 接口测试工具:封装请求执行、响应日志、字段校验
*/
@Slf4j
public class ApiTest {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final JsonNode EMPTY = objectMapper.createObjectNode();
private final WebTestClient client;
private String method;
private String uri;
private Object body;
private String token;
private int expectedStatus = 200;
private ApiTest(WebTestClient client) {
this.client = client;
}
// ========== 构建请求 ==========
public static ApiTest of(WebTestClient client) {
return new ApiTest(client);
}
public ApiTest get(String uri) {
this.method = "GET"; this.uri = uri; return this;
}
public ApiTest post(String uri, Object body) {
this.method = "POST"; this.uri = uri; this.body = body; return this;
}
public ApiTest put(String uri, Object body) {
this.method = "PUT"; this.uri = uri; this.body = body; return this;
}
public ApiTest delete(String uri) {
this.method = "DELETE"; this.uri = uri; return this;
}
public ApiTest withToken(String token) {
this.token = token; return this;
}
public ApiTest expectStatus(int status) {
this.expectedStatus = status; return this;
}
// ========== 执行请求并校验 ==========
@SafeVarargs
public final void verify(Function<JsonNode, Boolean>... checks) {
// 构建请求
WebTestClient.RequestHeadersSpec<?> spec = switch (method) {
case "GET" -> client.get().uri(uri);
case "DELETE" -> client.delete().uri(uri);
case "POST" -> client.post().uri(uri).bodyValue(body);
case "PUT" -> client.put().uri(uri).bodyValue(body);
default -> throw new IllegalStateException("未指定 HTTP 方法");
};
if (token != null) {
spec = spec.header("Authorization", "Bearer " + token);
}
// 执行请求并获取响应
byte[] responseBytes = spec.exchange()
.expectStatus().isEqualTo(expectedStatus)
.expectBody(byte[].class)
.returnResult()
.getResponseBody();
String responseBody = responseBytes != null
? new String(responseBytes, StandardCharsets.UTF_8) : "";
// 解析并打印响应
log.info("---------- {} {} ----------", method, uri);
log.info(" ✅ JSON Response: {}", responseBody.isEmpty() ? "(空)" : responseBody);
JsonNode json;
try {
json = responseBody.isEmpty() ? EMPTY : objectMapper.readTree(responseBody);
} catch (Exception e) {
json = EMPTY;
log.info(" ⚠️ Invalid JSON Response: {}", e.getMessage());
}
// 执行校验函数
for (var check : checks) {
check.apply(json);
}
}
// ========== 快捷校验函数 ==========
/** 校验字段值相等 -> 支持 "data.username" 点号路径 */
public static Function<JsonNode, Boolean> eq(String path, Object expected) {
return json -> {
JsonNode node = resolvePath(json, path);
Object actual = extractValue(node, expected);
if (Objects.equals(actual, expected)) {
log.info(" ✅ {} = {}", path, actual);
return true;
} else {
log.error(" ❌ {} 期望: {}, 实际: {}", path, expected, actual);
fail(path + " 校验失败: 期望 [" + expected + "], 实际 [" + actual + "]");
return false;
}
};
}
/** 校验字段不为空 */
public static Function<JsonNode, Boolean> notNull(String path) {
return json -> {
JsonNode node = resolvePath(json, path);
boolean present = !node.isMissingNode() && !node.isNull()
&& !node.asString().isEmpty();
if (present) {
log.info(" ✅ {} = {}", path, node.asString());
return true;
} else {
log.error(" ❌ {} 为空", path);
fail(path + " 不应为空");
return false;
}
};
}
/** 校验数组长度 */
public static Function<JsonNode, Boolean> size(String path, int expected) {
return json -> {
JsonNode node = resolvePath(json, path);
int actual = node.isArray() ? node.size() : -1;
if (actual == expected) {
log.info(" ✅ {}.size() = {}", path, actual);
return true;
} else {
log.error(" ❌ {}.size() 期望: {}, 实际: {}", path, expected, actual);
fail(path + " 数量校验失败: 期望 " + expected + ", 实际 " + actual);
return false;
}
};
}
// ========== 内部工具 ==========
/** 按点号路径解析 JsonNode,如 "data.username" -> json.get("data").get("username") */
private static JsonNode resolvePath(JsonNode root, String path) {
JsonNode current = root;
for (String part : path.split("\\.")) {
if (current == null || current.isNull() || current.isMissingNode()) {
return EMPTY;
}
current = current.get(part);
}
return current != null ? current : EMPTY;
}
/** 根据期望值类型自动提取 JsonNode 中的值 */
private static Object extractValue(JsonNode node, Object expected) {
if (node == null || node.isNull() || node.isMissingNode()) return null;
if (expected instanceof Integer) return node.asInt();
if (expected instanceof Long) return node.asLong();
if (expected instanceof Boolean) return node.asBoolean();
return node.asString();
}
}使用方式非常直观:
import static com.albertstack.aichat.util.ApiTest.*;
// 发送 GET 请求,校验多个字段
of(webTestClient).get("/api/ping")
.verify(eq("code", 200), eq("data", "pong"));
// 发送 POST 请求,支持点号路径访问嵌套字段
of(webTestClient).post("/api/auth/register", new RegisterRequest("Albert", "pass"))
.verify(eq("code", 200), eq("data.username", "Albert"), notNull("data.token"));
// 携带 Token
of(webTestClient).post("/api/topics", new TopicRequest("话题"))
.withToken(token)
.verify(eq("code", 200), notNull("data.id"));
// 期望 400 错误
of(webTestClient).post("/api/auth/login", new LoginRequest("nobody", "pass"))
.expectStatus(400)
.verify(eq("code", 400), eq("message", "用户名或密码错误"));
// 只验证状态码,不校验字段
of(webTestClient).get("/api/topics")
.expectStatus(401)
.verify();核心设计:
- 链式 API:
of(client).post(uri, body).withToken(token).verify(...)一行搞定请求+校验 - 函数式校验:
eq、notNull、size返回Function<JsonNode, Boolean>,作为可变参数传入verify - 点号路径:
"data.username"自动解析为json.get("data").get("username") - JsonNode 永不为 null:内部用
EMPTY空节点兜底,避免 NPE
10. 编写测试用例
src/test/java/com/albertstack/aichat/controller/PingControllerTest.java
package com.albertstack.aichat.controller;
import com.albertstack.aichat.common.ResultCode;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;
import static com.albertstack.aichat.util.ApiTest.*;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class PingControllerTest {
@Autowired
private WebTestClient webTestClient;
@Test
void ping_shouldReturnPong() {
of(webTestClient)
.get("/api/ping")
.verify(
eq("code", ResultCode.SUCCESS.getCode()),
eq("message", ResultCode.SUCCESS.getMessage()),
eq("data", "pong")
);
}
}运行测试,控制台输出类似:
---------- GET /api/ping ----------
✅ JSON Response: {"code":200,"message":"操作成功","data":"pong"}
✅ code = 200
✅ message = 操作成功
✅ data = pong11. 启动项目
启动项目后,用 curl 手动验证:
curl http://localhost:8080/api/ping12. 小结
从零搭建了 Spring Boot 4 后端项目骨架,完成了基础依赖配置和开发规范建立。
| 知识点 | 说明 |
|---|---|
| 项目创建 | Spring Initializr 创建项目,引入 WebFlux、R2DBC、H2、Lombok |
| Spring AI 依赖 | 手动添加 Spring AI Ollama 依赖,通过 BOM 管理版本 |
| 基础配置 | 数据库连接、Ollama 连接配置 |
| 统一响应 | ApiResponse<T> 响应对象 + ResultCode 响应码枚举 |
| 测试工具 | ApiTest 链式请求 + 函数式校验 + 日志输出 |
| 心跳接口 | PingController + 对应测试用例,验证项目可运行 |
接下来开始搭建前端项目,使用 Vue 3 + Vite 构建用户界面,并实现与后端的基本交互。