AI 问答系统(Chat 接口)
用户在前端输入问题,后端 Spring Boot 调用 AI 接口(如 OpenAI / 通义千问 / 自定义模型),返回回答并展示。

创建配置

  • JDK17
  • 在IDEA中勾选依赖项
    • SpringWeb //搭建REST接口,用于对外提供API
    • Spring Data Redis //使用Redis进行缓存/AI结果存储
    • MySQL Driver //用于连接数据库,存储用户、记录等结构化数据
    • Lombok //简化实体类开发,自动生成Getter/Setter/构造器等
  • 配置application.yml(src/main/resources)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server:
port: 8080 #默认端口号
spring:
#配置数据库
datasource:
url: jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=UTC
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver

#配置redis
data:
redis:
host: localhost
port: 6379

#JSON输出格式优化
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8

logging:
level:
root: info

配置测试之Redis

  • 先在本机启动Redis
  • 在src/main/java/com.explainsf/springai下新建一个包controller,编写TestController类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.explainsf.springai.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("test")
public class TestController {
@Autowired
private StringRedisTemplate stringRedisTemplate;

@GetMapping("/redis")
public String testRedis(){
//写入redis
stringRedisTemplate.opsForValue().set("test - key", "hello redis!");
//从redis里读
String value = stringRedisTemplate.opsForValue().get("test - key");
return "redis 中的值是:" + value;
}
}

开发问答环节

  1. @RestController注解有什么用?
  • 它是一个控制器类,用于处理前端发过来的请求

  1. @RequestMapping(“/test”)有什么用?
  • 为这个控制器类编写一个统一的URL前缀,所有这个类里的接口,都会带上这个前缀;比如我们写了@GetMapping(“/redis”),最终完整的路径就是/test/redis

  1. @Autowired有什么用?
  • 让Spring自动注入(创建和管理)这个对象,不然我们就需要手动的new。每一个新创建的对象都要进行一次注解添加

  1. @GetMapping有什么用?
  • 我们写了GetMapping('/redis'),就去执行下面的这个方法

  1. StringRedisTemplate是什么?opsForValue()方法又有什么用?
  • 是Spring提供的类,用来专门操控redis;opsForValue()用来操作字符串,有不同的方法可以操作不同的数据类型

Spring Boot 会把你写的每个接口,组合成一个 URL 路径:/test是前缀,/redis是后缀(调用的方法),拼在一起就可以
Redis测试

配置测试之MySQL

  • 先创建一个表(也叫做架构)
1
CREATE DATABASE demo_db;
  • 再使用架构并建表
1
2
3
4
5
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50),
email VARCHAR(100)
);

建表

  • 创建一个entity包,里面有User类

  • 创建一个repository包,目录在包名下,包里含有一个UserRepository接口

1
2
3
4
5
6
7
package com.explainsf.springai.repository;

import com.explainsf.springai.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
}

继承了Jpa这个类,我们自己的接口会有以下方法
继承Jpa

问答环节

  1. @Entity
  • 作用:标识这个类是一个实体类,会映射为数据库中的一张表。
  • 来自jakarta.persistence
  • 说明:如果没有这个注解,Spring Boot 不会将该类作为数据库表处理。

  1. @Table(name = "user")
  • 作用:指定实体类对应的数据库表名为 user
  • 默认行为:如果不写该注解,JPA 会默认使用类名作为表名(大小写敏感,容易报错)。

  1. @Id
  • 作用:标识该字段为主键(Primary Key)。
  • 必须使用:否则 JPA 无法知道如何唯一标识一条记录。

  1. @GeneratedValue(strategy = GenerationType.IDENTITY)
  • 作用:设置主键的生成策略为数据库自增(MySQL 中的 AUTO_INCREMENT)。
  • 常见策略
    • AUTO:由 JPA 自动选择策略
    • IDENTITY:数据库自增(适用于 MySQL)
    • SEQUENCE:使用序列(适用于 Oracle)
    • TABLE:使用数据库表记录主键(性能较差)

  1. @Data(Lombok 提供)
  • 作用:自动生成以下方法:
    • get / set
    • toString()
    • equals() / hashCode()
  • 简化代码: 相当于加了:@Getter @Setter @ToString @EqualsAndHashCode ,@RequiredArgsConstructor

    在下文中,虽然我们没写getMessage()方法,但是由于我们在ChatRequest这个类上加了Data注解,于是自动生成了Get方法
    数据库连接

配件测试之Qwen

  • 在application.yml中使用Qwen创建的api-key以及api-url(不同的模型有不同的http响应)
  • 在pom.xml配置dependecy
  • 在软件包下创建config包并在里面配置QwenConfig
  • 然后在Service里开发QwenService负责
  • 最后在ChatController中提供对外的接口

配置API之Qwen

  1. @Component有什么用?
  • 将这个类注册为Spring容器中的一个Bean,@Component是注册Bean,告诉 Spring 这个类/对象你要负责创建和管理。

    @Component:你告诉 Spring “这个类要托管给你管理”(注册到容器)
    @Autowired:你告诉 Spring “我要用那个托管的类”(注入使用)

  1. @ConfigurationProperties(prefix = “qwen”)有什么用?
  • SpringBoot会自动将application.yml中的api-key和api-url注入到QwenConfig中的配置环境。

到目前,后端的基础就已经写好了。

阶段性总结

  • 我们写的这个结构是SpringBoot的三层架构:
    • 配置类Config
    • 服务类Service
    • 控制器类Controller

功能完善

聊天接口完善

  • 支持POST请求
  • 返回统一的JSON格式
  1. 在包下新建model包,并在里面新建ChatRequest类和统一响应类
  2. 重写ChatRequest类(使用了@Data)
  3. 使用了统一返回类
  4. 最后使用PostMan进行POST请求的调试

聊天接口之完善问答

  1. 为什么要新建一个model并在里面创建ChatRequest和统一响应类?
  • 之前我们调用API的回答方式是/chat方法,是一种get请求,简单来说就是我们只需要传一个参数问题,浏览器就会给我们模型的回答,现在我们使用ChatRequest和统一响应类来接收JSON请求体,可以把接口返回格式统一,也是主流的开发。

//json格式混乱,我们使用json格式解析器回复更好

现在我们就成功调用了ai

前端页面

  • 创建一个chat.html(src/main/resources/templates/chat.html)
  • 修改ChatController作为页面路由
  • 前端代码生成
  • 但现在问题是:无法记录上下文,这时候我们就要用到redis存储历史记录

Redis实现上下文记录

  • 创建util包,里面存放着ChatMemoryManager实现类
    • ChatMemoryManager的实现类思路
      我们先使用了StringRedisTemplateObjectMapper这两个类,并且定义了最大历史记录为5.
  • 修改QwenService以及Controller,让它们带上了userId和Message
    • 我们使用了一个集合来存放历史消息

Redis问答

  1. StringRedisTemplate类是哪里来的?
  • 它是Spring提供的操作redis的类,专门用于字符串类型的读写操作。
  1. ObjectMapper是哪里来的?
  • 这是Jackson提供的核心类,用来进行java对象<–>JSON字符串的切换,在发送请求和存redis的时候会经常用到
  1. 那我们现在的redis会不会自动删除聊天记录呢?
  • 其实不会,使用的是 redisTemplate.opsForValue().set(key, value),也没有设置过期时间TTL,所以历史记录会永久存放在redis中;那我们是否需要删除呢?要取决于我们的上下文聊天是否持久
    • 保证每次进入页面都有记忆:不删除,可以永久存放在redis中
    • 只在本次对话中记忆:需要删除,聊完就清空redis
    • 长期登录用户的对话记录:建议写入数据库,redis仅做缓存,MySQL做历史保存,Redis做上下文
  1. 所以chatGPT的策略是什么?
  • 所有的记忆都是靠拼接历史对话内容到当前请求中完成的,并不是”主动记得”,当每次用户提问的时候,系统把之前的消息打包发送给模型。所谓的上下文能力全部来源于调用者的message消息列表
    • 调用chatGPT时,OpenAI会构造出这样一个message数组:
    1
    2
    3
    4
    5
    [
    { "role": "user", "content": "你好" },
    { "role": "assistant", "content": "你好,有什么我可以帮你的吗?" },
    { "role": "user", "content": "你知道我刚才问了什么吗?" }
    ]
    • 只有这样模型才知道刚刚发生了什么。
  • 所以我们有两种方式实现长期记忆
    1. 前端或服务端主动保存历史聊天
    • 每次用户发言就持久化(如存到 MySQL)
    • 用户进入新聊天时自动加载历史上下文到 Redis,再发送给模型
    1. 方式二:Embedding + 概念记忆(更高阶)
    • 把用户历史对话转为向量(embedding),存入向量库
    • 在新问题触发时从向量库中“召回相似的历史”,附加给 prompt

      现在构建的 Redis-based 对话记忆系统,是行业内标准方案。
      现在消息记录已经被成功存入到redis中了

      成功了!

MySQL实现持久化聊天记录到本地

  • 基于Redis我们可以实现上下文记忆,但我们需要保存聊天记录,有以下好处
    • MySQL可以持久化数据,适合做用户聊天记录展示、检索以及分析
    • Redis重启可能数据会丢失,MySQL可以长期备份保存
    • 用这些保存的数据继续去分析
    • 多端跨平台跨设备查看历史聊天记录

      🔒 Redis 用于短时上下文记忆(对话中),MySQL 用于长期存储与用户功能(查看历史、数据分析),两者配合才完整。

  1. 我们需要建表
1
2
3
4
5
6
7
8
CREATE TABLE chat_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
role VARCHAR(10) NOT NULL, -- user / assistant
content TEXT NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id)
);
  1. 在entity添加实体类ChatLog(对应MySQL表)

  2. 添加Repository(操作数据库)

数据库问答

  1. JPA是什么?
    (Java Persistence API) Java官方定义的一套ORM规范,可以把数据库当Java中的类来操作,把数据行当成对象来操作,我们使用的是SpringBoot + JPA
  • 实体类:@Entity + @Table描述数据库表结构
  • 接口:extends JpaRepository<实体,主键类型>直接获得数据库
  • 不用写SQL,自动生成

遇到问题


我们成功创建了MySQL和后端的连接,但是有个问题!我们没有注册登录用户,当我们拿一个从来没有插入进数据库的id进行回答时,虽然模型成功给出了答案,但是并没有成功在前端页面返回,是因为我们没有在user表里插入这个人,所以我们现在可以使用一个办法,先让用户注册并登录,让这个用户和chat_log表中的对应数据关联。

  • 所以我们接下来先实现用户注册登录功能
  • 我们有两张表,一张是chat_log,另一张是user,user里可以设置字段id以及password,在chat_log里面的表也有一个id字段,作为外键指向user表让两张表相连。

登陆注册

  1. 复写User类,新增password字段,@Data直接帮我们省去了Setter和Getter的写
  2. 写UserRepository,主要是添加
  3. 创建UserService实现登录注册逻辑
  4. 添加Spring Security依赖

登陆注册问题

  1. @Server注解有什么用?
  • 它是 Spring 的组件注解之一,用于标识一个类是“服务层组件”(Service)。被标注的类会被 Spring 容器自动识别并托管为 Bean,可以在其他地方通过 @Autowired 自动注入使用。
  1. @Override有什么用?
  • 表示当前方法是 重写了父类或接口中定义的方法。
  1. 我们采用了什么加密算法?
  • 加密方式:BCrypt 哈希加密,是一种不可逆的密码加密方式;

JWT登陆注册

既然我们已经有了数据库登录,那为什么还要做JWT登录?

  • 数据库
    • 当前数据库的登陆方式是用户登陆时提交ID和密码
    • 服务端查数据库验证密码
    • 前端之后每次请求仍需要传用户ID,服务端继续查数据库验证
    • 问题在于每一次都要去请求数据库,太麻烦了!
  • JWT
    • 我们在登陆成功后给用户发一个令牌token,代表用户身份,每次请求只去查询token,实现无状态认证。
    • 易扩展,可以加入用户角色、权限、过期时间等

      数据库登录是“身份验证的方式”,而 JWT 是“身份验证后的认证令牌机制”,两者是互补不是重复。

  1. 添加依赖
  2. 修改UserService.login和UserController
  • 登陆成功后生成JWT Token
  • 将Token和用户信息一起返回给前端
  • 创建一个DTO(LoginResponse)
  • 修改登录逻辑,使用LoginResponse类型来接收

    成功返回了jwt令牌

编写JwtAuthFilter实现Token校验与用户认证

  • 使用 OncePerRequestFilter 保证每次请求只调用一次;
  • 我们把 userId 放入了认证上下文,可以在控制器里用 @AuthenticationPrincipal 获取;
  • 不做角色权限处理,使用空权限列表 Collections.emptyList() 即可。
  1. 增加我们自定义的jwt过滤器
  • addFilterBefore(…) 是关键,把我们自定义的 JWT 过滤器插入到 Spring Security 过滤器链中;
  • 现在所有接口如果没有 Token,都会被拒绝访问(除了 permitAll() 的那些);

但现在又出现了新的问题

  • 我们在单一账号下能够实现记忆,但是用了两个不同的账号来回切换,就发现它们无法读取之前的记忆了,问题在于我们之前使用的是getMessages(userId)从 Redis 拉取历史,上下文丢失了

    用户登录或重新进入聊天时,如果 Redis 中无上下文,则从 MySQL 中加载最近 N 条对话记录“恢复记忆”。

  1. 在chat方法添加如果redis是空,从mysql里找回之前的消息
1
2
3
4
5
6
7
8
// Step 1: 如果 Redis 无上下文,自动从数据库恢复
if (chatMemory.getMessages(userId).isEmpty()) {
List<ChatLog> recent = chatLogRepository.findTop10ByUserIdOrderByCreateTimeDesc(Long.valueOf(userId));
Collections.reverse(recent); // 确保顺序正确(旧 → 新)
for (ChatLog log : recent) {
chatMemory.saveMessage(userId, log.getRole(), log.getContent());
}
}

成功加入历史记录!

部署上线

使用阿里云ECS

  • 下载XShell
  • 连接阿里云服务器
  • Maven打包Jar包
    • clean&&package
  • 下载XFTP上传Jar包
    • 上传成功后在Xshell输入nohup java -jar /root/my-project.jar > app.log 2>&1 &
  • 安装nginx作反向代理,去掉端口号
    成功上线!
    IP访问和域名访问
  • IP访问:直接访问
  • 域名访问:国内要备案

项目问题

Q:重点介绍一下你在CC-Chat项目中最具挑战性的部分,以及你是如何解决的。

A:在我独立开发的CC-Chat项目中,最具挑战的部分是实现上下文记忆功能。起初我通过Redis缓存用户的历史对话,实现大模型的上下文“模拟”。但在测试中发现模型无法记住历史对话,排查后发现由于用户切换账号,缓存数据被清除,导致上下文缺失。
为此,我引入MySQL存储完整历史记录,当缓存缺失时自动回源数据库构造上下文,从而保证了对话的连续性和智能性。这个过程让我更深入理解了缓存与持久化的协同机制,也锻炼了我定位问题和系统设计的能力。

Q:你刚才提到了 Redis 和 MySQL 配合实现上下文记忆。那请你详细讲讲你是如何设计 Redis 缓存结构的?具体用到了哪些数据结构或命令?是否考虑过数据过期和清理机制?

A:在Redis中,我为每个用户维护一个聊天上下文列表,采用Redis的List结构(如 lpush 和 lrange)按时间顺序缓存每轮对话。当用户发起新的请求时,我会根据用户ID取出最新的几条消息构造上下文。如果Redis缓存为空(如首次登录或缓存过期),我会从MySQL中查出该用户的历史记录,恢复构造Redis缓存,保证连续性。
同时我设置了缓存的过期时间(expire),避免内存占用过高为了防止缓存击穿,我也使用了双写策略:每次新对话既写入Redis也写入MySQL。此外,为了提升并发性能,设计了异步写入MySQL的方案。

第二期方案: “缓存写 + 异步落库”的方案。用户每次对话时,我会先将聊天数据写入Redis缓存,同时将该记录封装成消息推送到消息队列(如RabbitMQ),由异步消费线程统一批量写入MySQL。

  1. 写入快速响应:主线程只操作内存级别的Redis,不阻塞。
  2. 削峰填谷:队列起到缓冲作用,避免高并发时数据库写入雪崩。
  3. 数据一致性:我设置了失败重试机制和幂等处理,确保数据不会重复或丢失。
  • 幂等:幂等(Idempotent)处理是指无论执行多少次操作,结果都是一样的,不会因为重复执行而产生副作用。假设用户发送一条聊天消息,系统把这条消息保存到数据库。如果因为网络问题,这条请求被重试了3次,幂等处理能确保这条消息只保存一次,不会出现三条一样的记录。
    在 CC-Chat 项目的异步写入方案中,为避免消息重复消费导致聊天记录重复插入数据库,我设计了基于 Redis 的幂等处理机制。
    具体做法如下:
  1. 唯一标识:每条聊天消息生成一个唯一ID(如使用UUID或雪花算法)。
  2. 消息发送:当用户发送消息时,该ID连同消息体一起被封装发送到 RabbitMQ。
  3. 幂等校验:消费者在处理前,先去 Redis 查询该消息ID是否已处理。
  4. 若 Redis 中存在该ID,则跳过(说明是重复消息)。
  5. 若不存在,则处理并在 Redis 中用 setnx 命令记录该ID,并设置短期过期时间(如5分钟)。
  6. 落库写入:消息入库成功后,异步确认或更新 Redis 状态。

通过这种方式,即使 RabbitMQ 消息被重复投递或消费,依赖 Redis 的幂等校验可确保每条聊天消息只被写入一次,保证数据一致性与准确性。

Q:你使用了建表语句,这个数据库会无限的扩容下去吗?当我聊天的时候信息越来越多,数据库一直增长?

A:如果没有限制机制,数据库会默认一直增长,会带来查询变慢,磁盘空间不足,数据备份和迁移变得困难。
我想到了这个问题后打算在后续完善项目时采用以下几个策略进行问题解决

  • 分库分表:将大表用户按用户ID或时间进行分表,比如按月建表,提升效率
  • 历史数据归档:将半年以前的数据定期转移到归档表或冷存储中,仅保留最近活跃记录
  • 分页+索引优化: 配合时间字段建索引,并使用分页查询,避免全表扫描;
  • 使用外部存储:对非结构化数据(如图片、长文本)使用对象存储(如OSS)+URL引用,减轻数据库压力