3. 后端项目初始化

5 min

1. 创建项目目录

首先创建项目的顶层目录:

bash
mkdir ai-chat
cd ai-chat

2. 通过 Spring Initializr 创建项目

打开 start.spring.io,按以下配置创建项目:

Spring 项目初始化
  • 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/ 目录下,确保目录结构为:

text
ai-chat/
└── ai-chat-backend/
    ├── pom.xml
    ├── src/
    └── ...

3. 理解已引入的依赖

打开 pom.xml,Spring Initializr 已经帮我们引入了以下依赖:

xml
<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-testspring-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> 中添加:

xml
<!-- 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> 中添加:

xml
<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 里程碑仓库:

xml
<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,写入以下配置:

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

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

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

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 响应格式统一为:

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

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

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();
    }

}

使用方式非常直观:

java
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();

核心设计:

  • 链式 APIof(client).post(uri, body).withToken(token).verify(...) 一行搞定请求+校验
  • 函数式校验eqnotNullsize 返回 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

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")
                );
    }
}

运行测试,控制台输出类似:

text
---------- GET /api/ping ----------
 ✅ JSON Response: {"code":200,"message":"操作成功","data":"pong"}
 ✅ code = 200
 ✅ message = 操作成功
 ✅ data = pong

11. 启动项目

启动项目后,用 curl 手动验证:

bash
curl http://localhost:8080/api/ping
albert@dev: ~

12. 小结

从零搭建了 Spring Boot 4 后端项目骨架,完成了基础依赖配置和开发规范建立。

知识点 说明
项目创建 Spring Initializr 创建项目,引入 WebFlux、R2DBC、H2、Lombok
Spring AI 依赖 手动添加 Spring AI Ollama 依赖,通过 BOM 管理版本
基础配置 数据库连接、Ollama 连接配置
统一响应 ApiResponse<T> 响应对象 + ResultCode 响应码枚举
测试工具 ApiTest 链式请求 + 函数式校验 + 日志输出
心跳接口 PingController + 对应测试用例,验证项目可运行

接下来开始搭建前端项目,使用 Vue 3 + Vite 构建用户界面,并实现与后端的基本交互。