智能图书馆系统(Smart Library)
智能图书馆系统(Smart Library)技术学习文档
本文档基于项目实际源码,系统讲解后端各技术栈的知识点与业务实现,适合学习 Spring Boot 全栈开发。
目录
- 项目概览
- 技术栈总览
- 项目结构详解
- Spring Boot 核心知识
- Spring Security + JWT 认证
- MyBatis-Plus ORM 框架
- Redis 缓存应用
- Spring AI 智能体
- RAG 检索增强生成
- SSE 流式响应
- 业务模块详解
- 统一响应与异常处理
- API 文档(SpringDoc)
- 数据库设计
- 前端技术栈
- 部署与配置
1. 项目概览
智能图书馆系统(Smart Library)是一个以 AI 智能体为核心的图书馆管理系统,AI 助手名为"书灵"。
1.1 核心功能
| 功能模块 | 说明 |
|---|---|
| AI 智能体对话 | 读者通过自然语言查询图书、借阅归还、获取推荐 |
| 图书管理 | CRUD、封面上传、语义搜索(RAG)、分类浏览 |
| 借阅管理 | 借阅/归还流程、逾期状态跟踪、个人借阅记录 |
| 个性化推荐 | 基于借阅历史的 LLM 推荐,无历史时降级为热门 Top10 |
| 数据看板 | 管理员查看藏书量、借阅趋势、热门图书、分类占比、AI 对话统计 |
| 用户权限 | ADMIN(管理员)和 READER(读者)两种角色,JWT 认证 |
1.2 用户角色
- READER(读者):浏览图书、借阅/归还、AI 对话、收藏、评论、查看推荐
- ADMIN(管理员):以上全部 + 图书 CRUD、用户管理、数据看板、文件上传
1.3 关键约束
- 图书 ISBN 全局唯一;库存为 0 时拒绝借阅
- 逻辑删除的图书不出现在任何查询结果中
- AI 对话历史持久化到 MySQL,每次请求加载最近 6 条(3 轮)
- Ollama 不可用时系统降级:语义搜索退化为关键词搜索,推荐退化为热门列表
2. 技术栈总览
2.1 后端技术栈
| 组件 | 版本 | 用途 |
|---|---|---|
| Java | 17 | LTS 版本,支持 Record、文本块等新特性 |
| Spring Boot | 3.3.5 | 主框架,自动配置、内嵌 Tomcat |
| Spring AI | 1.0.0-M6 | AI 智能体框架,Tool Calling、RAG |
| Spring Security | 内置 | JWT 认证、角色权限控制 |
| MyBatis-Plus | 3.5.9 | ORM 框架,简化 CRUD |
| MySQL | 8.x | 主数据库 |
| Redis | 7.x | 缓存 + Token 存储 |
| jjwt | 0.12.6 | JWT 工具库 |
| Lombok | 内置 | 减少样板代码 |
| SpringDoc OpenAPI | 2.6.0 | Swagger UI / API 文档 |
| Ollama | 本地 | qwen2.5:3b(对话)+ nomic-embed-text(嵌入) |
2.2 前端技术栈
| 组件 | 版本 | 用途 |
|---|---|---|
| Vue 3 + TypeScript | ^3.5 | 前端框架,Composition API |
| Vite | ^7.1 | 构建工具 |
| Element Plus | ^2.11 | UI 组件库 |
| Tailwind CSS v4 | ^4.1 | 原子化 CSS |
| Pinia | ^3.0 | 状态管理 |
| ECharts | ^6.0 | 数据看板图表 |
| axios | ^1.12 | HTTP 请求 |
2.3 pom.xml 核心依赖解析
<!-- Spring AI BOM 统一管理版本 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0-M6</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<!-- Spring AI Ollama Starter(自动注册 ChatModel + EmbeddingModel) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<!-- MyBatis-Plus 3.5.9+ 需要额外引入 jsqlparser -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.9</version>
</dependency>
知识点:Spring AI 使用 Milestone 版本,需要配置 Spring Milestones 仓库才能下载。
3. 项目结构详解
3.1 后端包结构
cn.edu.cdu.smartlibrary
├── SmartLibraryApplication.java # 启动类
├── common/ # 通用类
│ ├── Result.java # 统一响应包装
│ ├── BusinessException.java # 业务异常
│ └── GlobalExceptionHandler.java # 全局异常处理
├── config/ # 配置类
│ ├── SecurityConfig.java # Spring Security 配置
│ ├── JwtAuthenticationFilter.java # JWT 过滤器
│ ├── SpringAIConfig.java # Spring AI 向量存储配置
│ ├── WebMvcConfig.java # 静态资源映射
│ ├── SwaggerConfig.java # Swagger UI 配置
│ ├── MybatisPlusConfig.java # 分页插件配置
│ └── MetaObjectHandlerConfig.java # 自动填充时间字段
├── agent/ # AI 智能体
│ ├── LibraryAgent.java # 智能体核心(RAG + Tool Calling)
│ ├── AgentTools.java # 工具集(@Tool 方法)
│ └── AgentPrompts.java # 系统提示词常量
├── controller/ # REST 控制器(10个)
├── service/ # 业务逻辑(接口 + Impl)
├── mapper/ # MyBatis-Plus Mapper 接口(8个)
├── entity/ # 数据库实体(8个)
├── dto/ # 请求入参 DTO(7个)
├── vo/ # 响应出参 VO(9个)
└── util/ # 工具类
└── JwtUtil.java # JWT 生成/解析
3.2 分层架构说明
HTTP 请求
↓
Controller(参数校验、调用 Service)
↓
Service(业务逻辑、事务管理)
↓
Mapper(数据库操作)
↓
MySQL / Redis
每层职责严格分离:
- Controller 只做参数校验(
@Valid)和调用 Service,不写业务逻辑 - Service 处理业务规则、事务、缓存
- Mapper 只做数据库 CRUD,复杂 SQL 用注解或 XML
3.3 DTO / VO 分离的意义
- DTO(Data Transfer Object):接收前端请求参数,带校验注解,防止非法数据进入业务层
- VO(View Object):返回给前端的数据,只暴露必要字段,隐藏敏感信息(如密码)
- Entity:与数据库表一一对应,不直接暴露给外部
// 错误做法:直接返回 Entity(会暴露 password、deleted 等字段)
public User getUser(Long id) { return userMapper.selectById(id); }
// 正确做法:转换为 VO 再返回
public UserVO getUser(Long id) {
User user = userMapper.selectById(id);
return UserVO.builder().id(user.getId()).username(user.getUsername()).build();
}
4. Spring Boot 核心知识
4.1 启动类
@SpringBootApplication
public class SmartLibraryApplication {
public static void main(String[] args) {
SpringApplication.run(SmartLibraryApplication.class, args);
}
}
@SpringBootApplication 是三个注解的组合:
@SpringBootConfiguration:标记为配置类@EnableAutoConfiguration:开启自动配置@ComponentScan:扫描当前包及子包的组件
4.2 application.yml 配置详解
spring:
datasource:
url: jdbc:mysql://localhost:3306/smart_library?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password:
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: localhost
port: 6379
timeout: 3000ms # 连接超时
ai:
ollama:
base-url: http://localhost:11434
chat:
model: qwen2.5:3b
options:
temperature: 0.7 # 生成随机性,0=确定性,1=最随机
embedding:
model: nomic-embed-text
servlet:
multipart:
max-file-size: 5MB # 单文件最大 5MB
max-request-size: 10MB # 请求体最大 10MB
app:
upload-dir: ./uploads
vector-store:
path: ./data/vectors.json
jwt:
secret: your-256-bit-secret-key-must-be-at-least-32-chars
expiration: 86400000 # 24小时(毫秒)
server:
port: 8080
servlet:
async:
timeout: 300000 # SSE 异步超时 5 分钟
知识点:
@Value("${app.jwt.secret}")可以注入 yml 中的自定义配置项。
4.3 常用注解速查
| 注解 | 位置 | 作用 |
|---|---|---|
@RestController |
类 | = @Controller + @ResponseBody,返回 JSON |
@RequestMapping |
类/方法 | 映射 URL 路径 |
@GetMapping / @PostMapping |
方法 | HTTP 方法快捷注解 |
@PathVariable |
参数 | 获取路径变量 /{id} |
@RequestParam |
参数 | 获取查询参数 ?page=1 |
@RequestBody |
参数 | 接收 JSON 请求体 |
@Valid |
参数 | 触发 Bean Validation 校验 |
@RequiredArgsConstructor |
类 | Lombok:生成 final 字段的构造器(替代 @Autowired) |
@Slf4j |
类 | Lombok:注入 log 日志对象 |
@Service |
类 | 标记为 Service Bean |
@Component |
类 | 标记为通用 Bean |
@Configuration |
类 | 标记为配置类 |
@Bean |
方法 | 声明一个 Bean |
4.4 依赖注入最佳实践
// 推荐:构造器注入(@RequiredArgsConstructor + final 字段)
@Service
@RequiredArgsConstructor
public class BookServiceImpl implements BookService {
private final BookMapper bookMapper; // final 字段
private final StringRedisTemplate redisTemplate;
private final VectorStoreService vectorStoreService;
}
// 不推荐:字段注入(无法测试,隐藏依赖关系)
@Autowired
private BookMapper bookMapper;
5. Spring Security + JWT 认证
5.1 整体认证流程
客户端请求
↓
JwtAuthenticationFilter(OncePerRequestFilter)
├── 提取 Token(Header 或 query param)
├── 黑名单校验(Redis blacklist:token)
├── JWT 签名验证(jjwt)
├── Redis 存活校验(token:userId)
└── 注入 SecurityContext(principal = userId)
↓
SecurityFilterChain(权限校验)
↓
Controller(@AuthenticationPrincipal Long userId)
5.2 SecurityConfig 配置
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // 开启方法级权限(@PreAuthorize)
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // 前后端分离,禁用 CSRF
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll() // 开发环境放行所有(生产需改为精细规则)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); // BCrypt 哈希,自带盐值
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:5173"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
// ...
}
}
知识点:
SessionCreationPolicy.STATELESS告诉 Spring Security 不创建 HttpSession,每次请求都通过 Token 验证身份。
5.3 JWT 过滤器实现
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (StringUtils.hasText(token)) {
// 1. 黑名单校验(登出后的 Token)
if (Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token))) {
filterChain.doFilter(request, response);
return;
}
if (jwtUtil.isTokenValid(token)) {
Long userId = jwtUtil.getUserId(token);
String role = jwtUtil.getRole(token);
// 2. Redis 存活校验(防止强制下线后 Token 仍有效)
String storedToken = redisTemplate.opsForValue().get("token:" + userId);
if (token.equals(storedToken)) {
// 3. 注入认证信息,principal 存 userId
var auth = new UsernamePasswordAuthenticationToken(
userId, null,
List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
}
filterChain.doFilter(request, response);
}
// SSE 连接无法设置 Header,支持从 query param 读取 token
private String extractToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
return header.substring(7);
}
return request.getParameter("token");
}
}
知识点:
OncePerRequestFilter保证每次请求只执行一次过滤,避免转发时重复执行。
5.4 JwtUtil 工具类
@Component
public class JwtUtil {
public String generateToken(Long userId, String username, String role) {
return Jwts.builder()
.subject(String.valueOf(userId))
.claim("username", username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getKey()) // HMAC-SHA256 签名
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private SecretKey getKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
}
JWT 结构:Header.Payload.Signature
- Header:算法类型(HS256)
- Payload:userId、username、role、过期时间
- Signature:用密钥对前两部分签名,防篡改
5.5 方法级权限控制
// 类级别:整个 Controller 都需要 ADMIN 角色
@PreAuthorize("hasRole('ADMIN')")
public class UserController { ... }
// 方法级别:单个接口需要 ADMIN 角色
@PreAuthorize("hasRole('ADMIN')")
@PostMapping
public Result<BookVO> create(@Valid @RequestBody BookDTO dto) { ... }
知识点:
hasRole('ADMIN')会自动匹配ROLE_ADMIN,因为 Spring Security 在存储时会加ROLE_前缀。
5.6 登出与 Token 黑名单
// 登出:删除 Redis Token + 加入黑名单
public void logout(String token, Long userId) {
redisTemplate.delete("token:" + userId);
// 黑名单 TTL 与 Token 过期时间一致(24h),到期自动清除
redisTemplate.opsForValue().set("blacklist:" + token, "1", 24, TimeUnit.HOURS);
}
Redis 中的 Key 设计:
token:{userId}→ 存储当前有效 Token(登录时写入,登出时删除)blacklist:{token}→ 黑名单(登出时写入,24h 后自动过期)
6. MyBatis-Plus ORM 框架
6.1 Entity 实体类规范
@Data
@TableName("book") // 对应数据库表名
public class Book {
@TableId(type = IdType.AUTO) // 主键自增
private Long id;
private String title;
private String author;
private String isbn;
@TableField(fill = FieldFill.INSERT) // 插入时自动填充
private LocalDateTime createdAt;
@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时自动填充
private LocalDateTime updatedAt;
@TableLogic // 逻辑删除字段(0=正常,1=已删除)
private Integer deleted;
}
自动填充配置(MetaObjectHandlerConfig):
@Component
public class MetaObjectHandlerConfig implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
}
知识点:
@TableLogic开启逻辑删除后,selectById、selectList等查询会自动加上WHERE deleted = 0条件,deleteById会执行UPDATE SET deleted = 1而非真正删除。
6.2 BaseMapper 内置方法
@Mapper
public interface BookMapper extends BaseMapper<Book> {
// 继承以下方法,无需手写 SQL:
// insert(T entity)
// deleteById(Serializable id)
// updateById(T entity)
// selectById(Serializable id)
// selectList(Wrapper<T> queryWrapper)
// selectPage(IPage<T> page, Wrapper<T> queryWrapper)
// selectCount(Wrapper<T> queryWrapper)
}
6.3 LambdaQueryWrapper 条件构造
// 多字段 OR 搜索(用 and() 包裹,避免优先级问题)
LambdaQueryWrapper<Book> wrapper = new LambdaQueryWrapper<Book>()
.and(w -> w.like(Book::getTitle, keyword)
.or().like(Book::getAuthor, keyword)
.or().like(Book::getIsbn, keyword))
.eq(Book::getCategory, category) // 精确匹配
.orderByDesc(Book::getCreatedAt); // 排序
// 分页查询
IPage<Book> page = bookMapper.selectPage(new Page<>(1, 12), wrapper);
page.getRecords(); // 当前页数据
page.getTotal(); // 总记录数
page.getPages(); // 总页数
知识点:
Book::getTitle是方法引用,MyBatis-Plus 通过反射解析出字段名title,再映射到列名title(驼峰转下划线由map-underscore-to-camel-case: true控制)。
6.4 自定义 SQL(注解方式)
@Mapper
public interface BorrowMapper extends BaseMapper<BorrowRecord> {
// 乐观锁扣减库存:WHERE stock > 0 防止超借
@Update("UPDATE book SET stock = stock - 1 WHERE id = #{bookId} AND stock > 0 AND deleted = 0")
int decreaseStock(@Param("bookId") Long bookId);
// 归还时库存 +1
@Update("UPDATE book SET stock = stock + 1 WHERE id = #{bookId} AND deleted = 0")
int increaseStock(@Param("bookId") Long bookId);
}
@Mapper
public interface ReviewMapper extends BaseMapper<BookReview> {
// 聚合查询:计算平均评分
@Select("SELECT AVG(rating) FROM book_review WHERE book_id = #{bookId} AND deleted = 0")
Double avgRating(Long bookId);
}
@Mapper
public interface RecommendMapper extends BaseMapper<AiRecommendation> {
// 热门图书 Top N(按借阅次数降序)
@Select("SELECT book_id, COUNT(*) as cnt FROM borrow_record GROUP BY book_id ORDER BY cnt DESC LIMIT #{topN}")
List<Long> findHotBookIds(int topN);
}
6.5 分页插件配置
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 添加分页拦截器,指定数据库类型
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
知识点:MyBatis-Plus 3.5.9+ 需要额外引入
mybatis-plus-jsqlparser依赖,否则分页插件无法正常工作。
6.6 逻辑删除全局配置
mybatis-plus:
global-config:
db-config:
logic-delete-field: deleted # 逻辑删除字段名
logic-delete-value: 1 # 删除标记值
logic-not-delete-value: 0 # 未删除标记值
configuration:
map-underscore-to-camel-case: true # 驼峰映射
7. Redis 缓存应用
7.1 Redis 在本项目中的用途
| 用途 | Key 格式 | TTL | 说明 |
|---|---|---|---|
| Token 存储 | token:{userId} |
24h | 登录时写入,登出时删除 |
| Token 黑名单 | blacklist:{token} |
24h | 登出时写入,防止 Token 复用 |
| 图书详情缓存 | book:detail:{id} |
30min | 更新/删除时主动清除 |
| 推荐结果缓存 | recommend:{userId} |
1h | 借阅后清除,下次重新生成 |
| 看板数据缓存 | dashboard:overview 等 |
10min | 减少统计查询压力 |
7.2 StringRedisTemplate 基本操作
@Service
@RequiredArgsConstructor
public class AuthServiceImpl {
private final StringRedisTemplate redisTemplate;
// 写入(带 TTL)
redisTemplate.opsForValue().set("token:" + userId, token, 24, TimeUnit.HOURS);
// 读取
String storedToken = redisTemplate.opsForValue().get("token:" + userId);
// 删除
redisTemplate.delete("token:" + userId);
// 判断 Key 是否存在
Boolean exists = redisTemplate.hasKey("blacklist:" + token);
}
7.3 缓存模式:Cache-Aside(旁路缓存)
本项目采用最常见的旁路缓存模式:
读操作:
1. 先查 Redis
2. 命中 → 直接返回
3. 未命中 → 查数据库 → 写入 Redis → 返回
写操作:
1. 更新数据库
2. 删除 Redis 缓存(而非更新,避免并发问题)
// 推荐服务中的缓存实现
public List<RecommendVO> getRecommendations(Long userId) {
// 1. 先查缓存
String cached = redisTemplate.opsForValue().get("recommend:" + userId);
if (cached != null) {
return objectMapper.readValue(cached, new TypeReference<>() {});
}
// 2. 缓存未命中,执行业务逻辑
List<RecommendVO> result = generateRecommendations(userId);
// 3. 写入缓存(1小时 TTL)
redisTemplate.opsForValue().set("recommend:" + userId,
objectMapper.writeValueAsString(result), 1, TimeUnit.HOURS);
return result;
}
// 借阅成功后清除推荐缓存
public void evictCache(Long userId) {
redisTemplate.delete("recommend:" + userId);
}
7.4 看板数据缓存封装
// 通用缓存包装方法(函数式编程风格)
private Map<String, Object> cached(String key,
Supplier<Map<String, Object>> supplier) {
try {
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return objectMapper.readValue(cached, Map.class);
}
} catch (Exception e) {
log.warn("Cache read error: {}", e.getMessage());
}
Map<String, Object> result = supplier.get();
try {
redisTemplate.opsForValue().set(key,
objectMapper.writeValueAsString(result), 10, TimeUnit.MINUTES);
} catch (Exception e) {
log.warn("Cache write error: {}", e.getMessage());
}
return result;
}
// 使用方式(Lambda 表达式传入数据库查询逻辑)
public Map<String, Object> getOverview() {
return cached("dashboard:overview", () -> {
long totalBooks = bookMapper.selectCount(null);
long totalUsers = userMapper.selectCount(null);
return Map.of("totalBooks", totalBooks, "totalUsers", totalUsers);
});
}
知识点:
Supplier<T>是 Java 函数式接口,() -> { ... }是 Lambda 表达式,只有在缓存未命中时才会执行数据库查询,实现懒加载。
8. Spring AI 智能体
8.1 Spring AI 核心概念
| 概念 | 说明 |
|---|---|
ChatClient |
与 LLM 交互的核心客户端,支持同步和流式调用 |
ChatModel |
底层模型接口,由 Starter 自动注册(OllamaChatModel) |
EmbeddingModel |
文本向量化接口,由 Starter 自动注册 |
Tool Calling |
让 LLM 调用预定义的 Java 方法,实现 AI 与业务系统交互 |
RAG |
检索增强生成,将相关文档注入 Prompt,提升回答准确性 |
SimpleVectorStore |
内存向量存储,支持 JSON 文件持久化 |
8.2 ChatClient 使用方式
@Component
@RequiredArgsConstructor
public class LibraryAgent {
private final ChatClient.Builder chatClientBuilder;
private final AgentTools agentTools;
// 每次调用都构建新的 ChatClient,避免 Builder 有状态累积
private ChatClient buildClient() {
return chatClientBuilder.build();
}
// 普通对话(同步,返回完整响应)
public ChatResponseVO chat(Long sessionId, Long userId, String message) {
List<Message> messages = buildMessagesWithRag(sessionId, message);
String answer = buildClient()
.prompt()
.messages(messages) // 传入消息列表(含历史)
.tools(agentTools) // 注册工具集
.call()
.content(); // 获取文本内容
return ChatResponseVO.builder().answer(answer).build();
}
}
8.3 Tool Calling 工具调用
Tool Calling 让 LLM 在对话中自动决定是否调用 Java 方法,实现 AI 与业务系统的深度集成。
@Component
@RequiredArgsConstructor
public class AgentTools {
private final BookService bookService;
private final BorrowService borrowService;
// @Tool 注解:description 是给 LLM 看的工具说明,LLM 根据用户意图决定是否调用
@Tool(description = "按关键词和分类搜索本馆馆藏图书,返回图书列表(含封面URL、库存)")
public String searchBooks(String keyword, String category) {
try {
IPage<BookVO> page = bookService.listBooks(1, 10, keyword, category);
return toJson(Map.of("books", page.getRecords(), "total", page.getTotal()));
} catch (Exception e) {
// 工具方法必须捕获异常,返回 error 字段,避免中断对话
return toJson(Map.of("error", e.getMessage()));
}
}
@Tool(description = "借阅指定图书。参数 bookIds 是图书ID,支持单个或逗号分隔的多个")
public String borrowBook(String bookIds) {
try {
Long userId = currentUserId(); // 从 SecurityContext 获取当前用户
if (userId == null) return toJson(Map.of("error", "用户未登录"));
// ... 执行借阅逻辑
} catch (Exception e) {
return toJson(Map.of("error", e.getMessage(), "success", false));
}
}
// 从 SecurityContext 获取当前登录用户 ID(不通过参数传入,防止越权)
private Long currentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return (Long) auth.getPrincipal();
}
}
Tool Calling 执行流程:
用户:"帮我借《三体》"
↓
LLM 分析意图 → 决定调用 searchBooks("三体", null)
↓
searchBooks 返回图书列表(含 bookId)
↓
LLM 分析结果 → 决定调用 borrowBook("1")
↓
borrowBook 执行借阅 → 返回成功结果
↓
LLM 生成最终回答:"已成功为您借阅《三体》,应还日期为..."
8.4 系统提示词(System Prompt)
public class AgentPrompts {
public static final String SYSTEM_PROMPT = """
你是"书灵",智能图书馆的 AI 助手。你的职责是帮助读者查询图书、借阅归还、获取推荐。
回答规范:
1. 用中文回答,语气友好自然
2. 需要查询图书信息时,直接调用工具获取,不要猜测
3. 推荐图书时,必须先调用工具查询本馆馆藏,只能推荐实际存在的图书
4. 如果工具查询结果为空,如实告知用户本馆暂无相关馆藏,不要补充虚构书目
5. 用户明确要求借阅或归还时,直接调用对应工具执行,无需再次询问确认
6. 归还图书时,如果用户没有提供借阅记录ID,先调用 getMyBorrowRecords 查询
""";
}
知识点:System Prompt 是给 LLM 的"角色设定",在每次对话开始时注入,告诉 LLM 它是谁、应该怎么做。使用 Java 文本块(
""")可以方便地写多行字符串。
8.5 对话历史管理
// 加载最近 6 条消息(3 轮对话)
public List<AiChatMessage> loadHistory(Long sessionId) {
List<AiChatMessage> all = messageMapper.selectList(
new LambdaQueryWrapper<AiChatMessage>()
.eq(AiChatMessage::getSessionId, sessionId)
.orderByDesc(AiChatMessage::getCreatedAt)
.last("LIMIT 6") // 只取最近 6 条
);
Collections.reverse(all); // 反转为时间升序
return all;
}
// 构建完整消息列表
private List<Message> buildMessagesWithRag(Long sessionId, String message) {
String ragContext = buildRagContext(message);
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(AgentPrompts.SYSTEM_PROMPT + ragContext)); // System
messages.addAll(buildHistory(sessionId)); // 历史消息
messages.add(new UserMessage(message)); // 当前用户消息
return messages;
}
9. RAG 检索增强生成
9.1 RAG 是什么
RAG(Retrieval-Augmented Generation,检索增强生成)是一种将外部知识库与 LLM 结合的技术:
用户问题
↓
向量化(Embedding)→ 查询向量
↓
向量数据库相似度搜索 → 相关文档
↓
将文档注入 Prompt → LLM 生成回答
优势:LLM 只知道训练数据,不知道本馆藏书。通过 RAG,可以让 LLM 基于实时馆藏数据回答问题。
9.2 向量存储配置
@Configuration
public class SpringAIConfig {
@Value("${app.vector-store.path}")
private String vectorStorePath;
@Bean
public SimpleVectorStore simpleVectorStore(EmbeddingModel embeddingModel) {
// EmbeddingModel 由 spring-ai-ollama-spring-boot-starter 自动注册
SimpleVectorStore store = SimpleVectorStore.builder(embeddingModel).build();
File file = new File(vectorStorePath);
if (file.exists()) {
try {
store.load(file); // 启动时加载持久化的向量数据
log.info("Vector store loaded from {}", vectorStorePath);
} catch (Exception e) {
log.warn("Failed to load vector store, will rebuild: {}", e.getMessage());
}
}
return store;
}
}
9.3 图书向量化索引
@Service
@RequiredArgsConstructor
public class VectorStoreServiceImpl implements VectorStoreService {
private final SimpleVectorStore vectorStore;
// 将图书信息向量化并存入 VectorStore
public void indexBook(Book book) {
try {
removeBook(book.getId()); // 先删除旧索引(SimpleVectorStore 不支持 upsert)
// 构建用于向量化的文本(书名 + 作者 + 分类 + 简介)
String content = buildContent(book);
// Document 包含文本内容和元数据(bookId 用于后续查询)
Document doc = new Document(content,
Map.of("bookId", String.valueOf(book.getId())));
vectorStore.add(List.of(doc)); // 向量化并存储
persist(); // 持久化到 JSON 文件
} catch (Exception e) {
// 向量化失败不影响主流程
log.warn("Failed to index book {}: {}", book.getId(), e.getMessage());
}
}
// 语义搜索:返回最相似的 topK 个图书 ID
public List<Long> semanticSearch(String query, int topK) {
try {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(topK).build()
);
return docs.stream()
.map(d -> Long.parseLong((String) d.getMetadata().get("bookId")))
.toList();
} catch (Exception e) {
// Ollama 不可用时返回空列表,调用方降级为关键词搜索
log.warn("Semantic search failed: {}", e.getMessage());
return Collections.emptyList();
}
}
private String buildContent(Book book) {
return "书名:" + book.getTitle() + "\n" +
"作者:" + book.getAuthor() + "\n" +
"分类:" + book.getCategory() + "\n" +
"简介:" + book.getDescription();
}
// 持久化到 JSON 文件,重启后不丢失
private void persist() {
File file = new File(vectorStorePath);
file.getParentFile().mkdirs();
vectorStore.save(file);
}
}
9.4 RAG 在智能体中的应用
// LibraryAgent 中的 RAG 上下文构建
private String buildRagContext(String message) {
try {
// 截断 query,nomic-embed-text 对超长输入会返回 400
String query = message.length() > 500 ? message.substring(0, 500) : message;
List<Long> bookIds = vectorStoreService.semanticSearch(query, 3); // topK=3
if (bookIds.isEmpty()) return "";
return "\n\n[相关图书ID参考:" + bookIds + ",可通过工具查询详情]";
} catch (Exception e) {
log.warn("RAG context build failed, skipping: {}", e.getMessage());
return ""; // 降级:不注入 RAG 上下文
}
}
9.5 语义搜索与关键词搜索融合
// BookServiceImpl 中的语义搜索实现
public Map<String, Object> semanticSearch(String query, int page, int size) {
// 1. 向量检索(topK=10)
List<Long> semanticIds = vectorStoreService.semanticSearch(query, 10);
boolean degraded = semanticIds.isEmpty(); // 是否降级
// 2. 关键词搜索(作为补充)
IPage<Book> keywordPage = bookMapper.selectPage(new Page<>(page, size),
new LambdaQueryWrapper<Book>()
.and(w -> w.like(Book::getTitle, query)
.or().like(Book::getAuthor, query)));
// 3. 合并去重(语义结果优先,LinkedHashMap 保序)
Map<Long, BookVO> merged = new LinkedHashMap<>();
for (Long id : semanticIds) {
Book b = bookMapper.selectById(id);
if (b != null) merged.put(id, toVO(b));
}
for (Book b : keywordPage.getRecords()) {
merged.putIfAbsent(b.getId(), toVO(b)); // 不覆盖已有的语义结果
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("records", pageData);
result.put("degraded", degraded); // 前端据此显示降级提示
return result;
}
10. SSE 流式响应
10.1 SSE 是什么
SSE(Server-Sent Events,服务器推送事件)是一种服务器向客户端单向推送数据的技术,适合 AI 对话的打字机效果。
| 对比 | SSE | WebSocket |
|---|---|---|
| 方向 | 单向(服务器→客户端) | 双向 |
| 协议 | HTTP | WS |
| 断线重连 | 浏览器自动重连 | 需手动实现 |
| 适用场景 | AI 流式输出、实时通知 | 聊天室、游戏 |
10.2 后端 SSE 实现
@GetMapping("/chat/stream")
public SseEmitter chatStream(@AuthenticationPrincipal Long userId,
@RequestParam Long sessionId,
@RequestParam String message) {
SseEmitter emitter = new SseEmitter(180_000L); // 超时 180 秒
// 在 Tomcat 线程捕获 SecurityContext(ThreadLocal,线程切换后会丢失)
SecurityContext securityContext = SecurityContextHolder.getContext();
// 使用独立线程池,避免阻塞 Tomcat 线程
sseExecutor.execute(() -> {
// 将 SecurityContext 注入子线程(AgentTools 需要获取当前用户)
SecurityContextHolder.setContext(securityContext);
StringBuilder fullContent = new StringBuilder();
try {
libraryAgent.stream(sessionId, userId, message)
.doOnNext(token -> {
// 逐 token 推送
emitter.send(SseEmitter.event()
.data(objectMapper.writeValueAsString(
Map.of("type", "token", "content", token))));
fullContent.append(token);
})
.doOnComplete(() -> {
// 完成时保存消息并发送 done 事件
AiChatMessage msg = chatSessionService.saveMessage(
sessionId, "ASSISTANT", fullContent.toString(), null);
emitter.send(SseEmitter.event()
.data(objectMapper.writeValueAsString(
Map.of("type", "done", "messageId", msg.getId()))));
emitter.complete();
})
.doOnError(e -> {
emitter.send(SseEmitter.event()
.data(objectMapper.writeValueAsString(
Map.of("type", "error", "message", e.getMessage()))));
emitter.complete();
})
.subscribe();
} finally {
SecurityContextHolder.clearContext(); // 清理子线程的 SecurityContext
}
});
return emitter;
}
10.3 流式响应的特殊处理
Spring AI M6 的 stream().content() 只捕获第一轮文本流,Tool Calling 后的第二轮回答会丢失。本项目的解决方案:
// LibraryAgent.stream() 的实现
public Flux<String> stream(Long sessionId, Long userId, String message) {
try {
List<Message> messages = buildMessagesWithRag(sessionId, message);
// 用 call() 获取完整答案(包含 Tool Calling 后的最终回答)
String answer = buildClient()
.prompt()
.messages(messages)
.tools(agentTools)
.call()
.content();
// 将完整答案按字符逐个推送,模拟打字机效果
return Flux.fromArray(answer.chars()
.mapToObj(c -> String.valueOf((char) c))
.toArray(String[]::new));
} catch (Exception e) {
return Flux.just("AI 服务暂时不可用,请稍后再试。");
}
}
知识点:
Flux<String>是 Project Reactor 的响应式流,Flux.fromArray()将数组转换为流,每个元素异步推送。
10.4 前端 SSE 连接(Vue 3)
// 前端使用原生 EventSource 连接 SSE
function startStream(sessionId: number, message: string) {
const token = localStorage.getItem('token')
// SSE 无法设置 Header,通过 query param 传 token
const url = `/api/agent/chat/stream?sessionId=${sessionId}&message=${encodeURIComponent(message)}&token=${token}`
const es = new EventSource(url)
es.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'token') {
assistantMsg.value += data.content // 逐字追加,Vue 自动重渲染
} else if (data.type === 'done') {
es.close() // 关闭连接
} else if (data.type === 'error') {
es.close()
}
}
// 组件卸载时必须关闭,防止内存泄漏
onUnmounted(() => es.close())
}
11. 业务模块详解
11.1 认证模块(Auth)
接口列表:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login |
用户登录 |
| POST | /api/auth/register |
用户注册(角色:READER) |
| POST | /api/auth/register/admin |
管理员注册(Demo 专用) |
| POST | /api/auth/logout |
登出 |
| GET | /api/auth/me |
获取当前用户信息 |
| PUT | /api/auth/password |
修改密码 |
登录流程:
public LoginVO login(LoginDTO dto) {
// 1. 查询用户
User user = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getUsername, dto.getUsername()));
// 2. 校验密码(BCrypt 验证)
if (user == null || !passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
throw new BusinessException(401, "用户名或密码错误");
}
// 3. 生成 JWT Token
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
// 4. 写入 Redis(TTL 24h)
redisTemplate.opsForValue().set("token:" + user.getId(), token, 24, TimeUnit.HOURS);
return LoginVO.builder()
.token(token)
.username(user.getUsername())
.role(user.getRole())
.avatar(user.getAvatarUrl())
.build();
}
DTO 校验注解:
@Data
@Schema(description = "注册请求")
public class RegisterDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 32, message = "用户名长度 3-32 位")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 64, message = "密码长度 6-64 位")
private String password;
@Schema(description = "邮箱(可选)")
private String email; // 无 @NotBlank,可选字段
}
11.2 图书模块(Book)
接口列表:
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/books |
公开 | 分页查询图书列表 |
| GET | /api/books/{id} |
公开 | 获取图书详情 |
| POST | /api/books |
ADMIN | 新增图书 |
| PUT | /api/books/{id} |
ADMIN | 更新图书 |
| DELETE | /api/books/{id} |
ADMIN | 删除图书 |
| POST | /api/books/reindex |
ADMIN | 重建向量索引 |
| GET | /api/books/search/semantic |
公开 | 语义搜索 |
新增图书流程:
public BookVO createBook(BookDTO dto) {
// 1. ISBN 唯一校验
Long count = bookMapper.selectCount(new LambdaQueryWrapper<Book>()
.eq(Book::getIsbn, dto.getIsbn()));
if (count > 0) throw new BusinessException(409, "ISBN 已存在");
// 2. 创建实体,初始库存 = 总量
Book book = fromDTO(dto);
book.setStock(dto.getTotal());
bookMapper.insert(book);
// 3. 向量化索引(异步,失败不影响主流程)
vectorStoreService.indexBook(book);
return toVO(book);
}
更新图书的库存调整逻辑:
// 库存调整:新库存 = 旧库存 + (新总量 - 旧总量)
// 例:原总量10,已借3本,库存7。改总量为12,则新库存 = 7 + (12-10) = 9
int stockDiff = dto.getTotal() - book.getTotal();
book.setStock(Math.max(0, book.getStock() + stockDiff)); // 不允许为负
11.3 借阅模块(Borrow)
接口列表:
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/borrows |
登录 | 借阅图书 |
| PUT | /api/borrows/{id}/return |
登录 | 归还图书 |
| GET | /api/borrows/my |
登录 | 我的借阅记录 |
| GET | /api/borrows |
ADMIN | 全量借阅记录 |
乐观锁防超借:
@Transactional(rollbackFor = Exception.class)
public BorrowVO borrowBook(Long userId, Long bookId) {
Book book = bookService.getBookEntity(bookId);
// 乐观锁:UPDATE book SET stock = stock - 1
// WHERE id = ? AND stock > 0 AND deleted = 0
// 返回受影响行数,0 表示库存不足(并发安全)
int affected = borrowMapper.decreaseStock(bookId);
if (affected == 0) {
throw new BusinessException(400, "库存不足,无法借阅");
}
// 创建借阅记录(默认借期 30 天)
BorrowRecord record = new BorrowRecord();
record.setUserId(userId);
record.setBookId(bookId);
record.setBorrowDate(LocalDateTime.now());
record.setDueDate(LocalDateTime.now().plusDays(30));
record.setStatus("BORROWED");
borrowMapper.insert(record);
// 借阅成功后清除推荐缓存
recommendService.evictCache(userId);
return toVO(record, book, null);
}
知识点:
@Transactional(rollbackFor = Exception.class)表示遇到任何异常都回滚事务。默认只回滚RuntimeException,加上rollbackFor = Exception.class可以捕获受检异常。
逾期状态实时判断:
// 查询时实时判断逾期,避免引入定时任务
public IPage<BorrowVO> getMyBorrows(Long userId, int page, int size) {
IPage<BorrowRecord> recordPage = borrowMapper.selectPage(...);
LocalDateTime now = LocalDateTime.now();
for (BorrowRecord r : recordPage.getRecords()) {
if ("BORROWED".equals(r.getStatus()) && r.getDueDate().isBefore(now)) {
r.setStatus("OVERDUE");
borrowMapper.updateById(r); // 更新数据库状态
}
}
return recordPage.convert(r -> toVO(r, ...));
}
11.4 收藏模块(Favorite)
接口列表:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/favorites/{bookId} |
收藏图书 |
| DELETE | /api/favorites/{bookId} |
取消收藏 |
| GET | /api/favorites/my |
我的收藏列表 |
| GET | /api/favorites/check/{bookId} |
检查是否已收藏 |
重复收藏处理:
public void addFavorite(Long userId, Long bookId) {
bookService.getBookEntity(bookId); // 校验图书存在
UserFavorite fav = new UserFavorite();
fav.setUserId(userId);
fav.setBookId(bookId);
try {
favoriteMapper.insert(fav);
} catch (DuplicateKeyException e) {
// 数据库唯一约束(userId + bookId)触发,返回 409
throw new BusinessException(409, "已收藏该图书");
}
}
知识点:利用数据库唯一约束(而非先查后插)来防止重复收藏,避免并发场景下的竞态条件。
11.5 评论模块(Review)
接口列表:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/reviews |
提交评论 |
| GET | /api/reviews/book/{bookId} |
获取图书评论列表 |
| GET | /api/reviews/book/{bookId}/rating |
获取图书平均评分 |
评论 DTO 校验:
@Data
@Schema(description = "提交评论请求")
public class ReviewDTO {
@NotNull(message = "图书ID不能为空")
private Long bookId;
@NotNull(message = "评分不能为空")
@Min(value = 1, message = "评分最低1分")
@Max(value = 5, message = "评分最高5分")
private Integer rating;
@Schema(description = "评论内容(可选)")
private String content; // 无 @NotBlank,允许只打分不写内容
}
一人一评限制:
public ReviewVO submitReview(Long userId, ReviewDTO dto) {
Long count = reviewMapper.selectCount(new LambdaQueryWrapper<BookReview>()
.eq(BookReview::getUserId, userId)
.eq(BookReview::getBookId, dto.getBookId()));
if (count > 0) throw new BusinessException(409, "您已评论过该图书");
// ...
}
11.6 推荐模块(Recommend)
推荐逻辑流程:
getRecommendations(userId)
↓
查 Redis 缓存(recommend:{userId})
├── 命中 → 直接返回
└── 未命中
↓
查最近 20 条借阅记录
├── 无记录 → getHotRecommendations()(热门 Top10)
└── 有记录 → getAiRecommendations()
↓
构建偏好描述(书名 + 分类)
↓
获取全库图书列表(最多50本)
↓
调用 LLM 生成推荐(返回 JSON)
↓
解析 JSON → 查询图书详情
↓
写入 ai_recommendation 表
↓
写入 Redis 缓存(1h TTL)
LLM 推荐 Prompt 设计:
String prompt = String.format("""
用户最近借阅了:%s
图书库中有以下图书(格式:ID:书名):%s
请根据用户的阅读偏好,从图书库中推荐 5 本图书。
返回 JSON 数组格式,每项包含 bookId(数字)和 reason(推荐理由,50字以内)。
只返回 JSON,不要其他内容。示例:[{"bookId":1,"reason":"..."}]
""", preference, bookList);
从 LLM 响应中提取 JSON:
// LLM 可能在 JSON 前后加上说明文字,需要提取纯 JSON
private String extractJson(String response) {
int start = response.indexOf('[');
int end = response.lastIndexOf(']');
if (start >= 0 && end > start) {
return response.substring(start, end + 1);
}
return "[]";
}
11.7 数据看板模块(Dashboard)
接口列表(仅 ADMIN):
| 路径 | 说明 |
|---|---|
/api/dashboard/overview |
总览统计(借阅中、逾期、总藏书、总用户) |
/api/dashboard/borrow-trend |
30天借阅趋势(折线图数据) |
/api/dashboard/top-books |
热门图书 Top10(柱状图数据) |
/api/dashboard/category-stats |
分类借阅统计(饼图数据) |
/api/dashboard/ai-stats |
AI 对话统计(近7天会话数和消息数) |
ECharts 数据格式(后端直接返回前端可用的格式):
// 折线图数据格式
Map.of(
"xAxis", List.of("04-01", "04-02", ...), // X 轴日期
"series", List.of(
Map.of("name", "借阅量", "data", List.of(5, 8, 3, ...))
)
)
// 饼图数据格式
Map.of(
"series", List.of(
Map.of("name", "分类借阅", "data", List.of(
Map.of("name", "计算机", "value", 45),
Map.of("name", "文学", "value", 32)
))
)
)
11.8 AI 对话模块(Agent)
接口列表:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/agent/sessions |
创建对话会话 |
| GET | /api/agent/sessions |
获取会话列表 |
| DELETE | /api/agent/sessions/{id} |
删除会话 |
| GET | /api/agent/sessions/{id}/messages |
获取会话消息历史 |
| POST | /api/agent/chat |
发送消息(JSON 响应) |
| GET | /api/agent/chat/stream |
发送消息(SSE 流式响应) |
会话标题自动生成:
public AiChatMessage saveMessage(Long sessionId, String role, String content, String sources) {
// ...
AiChatSession session = sessionMapper.selectById(sessionId);
if (session != null) {
// 首条 USER 消息自动截取前 20 字作为会话标题
if ("USER".equals(role) && "新对话".equals(session.getTitle())) {
session.setTitle(content.length() > 20 ? content.substring(0, 20) : content);
}
session.setUpdatedAt(LocalDateTime.now());
sessionMapper.updateById(session);
}
return msg;
}
越权访问防护:
// 校验会话归属,防止用户访问他人会话
public void validateSessionOwner(Long userId, Long sessionId) {
AiChatSession session = sessionMapper.selectById(sessionId);
if (session == null || !session.getUserId().equals(userId)) {
throw new BusinessException(403, "无权访问该会话");
}
}
SSE 线程安全问题:
// 问题:SecurityContextHolder 默认使用 ThreadLocal,线程切换后 context 丢失
// 解决:在 Tomcat 线程捕获 context,传入子线程
// Tomcat 线程中
SecurityContext securityContext = SecurityContextHolder.getContext();
// 子线程中
sseExecutor.execute(() -> {
SecurityContextHolder.setContext(securityContext); // 注入 context
try {
// ... 执行 AI 对话(AgentTools 中需要获取当前用户)
} finally {
SecurityContextHolder.clearContext(); // 清理,防止内存泄漏
}
});
11.9 文件上传模块(File)
接口列表:
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/files/upload/cover |
ADMIN | 上传图书封面 |
| POST | /api/files/upload/avatar |
登录 | 上传用户头像 |
文件存储服务:
@Service
public class FileStorageService {
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png", "image/webp"
);
public String storeFile(MultipartFile file, String subDir) {
// 1. 校验文件非空
if (file.isEmpty()) throw new BusinessException(400, "文件不能为空");
// 2. 校验文件类型(白名单)
if (!ALLOWED_TYPES.contains(file.getContentType())) {
throw new BusinessException(400, "仅支持 jpg/png/webp 格式");
}
// 3. 校验文件大小(5MB)
if (file.getSize() > 5 * 1024 * 1024) {
throw new BusinessException(400, "文件大小不能超过 5MB");
}
// 4. 生成随机文件名(UUID),防止文件名冲突和路径遍历攻击
String ext = getExtension(file.getOriginalFilename());
String filename = UUID.randomUUID() + ext;
// 5. 保存到本地目录
Path dir = Paths.get(uploadDir, subDir);
Files.createDirectories(dir);
Files.copy(file.getInputStream(), dir.resolve(filename));
// 6. 返回访问 URL
return "/uploads/" + subDir + "/" + filename;
}
}
静态资源映射(WebMvcConfig):
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// 将 /uploads/** 映射到本地 ./uploads/ 目录
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:" + uploadDir + "/");
}
}
12. 统一响应与异常处理
12.1 统一响应格式 Result
@Data
public class Result<T> {
private int code;
private String message;
private T data;
private 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);
}
}
响应示例:
// 成功
{ "code": 200, "message": "success", "data": { "id": 1, "title": "三体" } }
// 失败
{ "code": 404, "message": "图书不存在", "data": null }
// 参数校验失败
{ "code": 400, "message": "用户名不能为空", "data": null }
12.2 业务异常 BusinessException
@Getter
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
}
使用方式:
// 在 Service 中直接抛出,不需要 try-catch
if (count > 0) throw new BusinessException(409, "ISBN 已存在");
if (book == null) throw new BusinessException(404, "图书不存在");
if (!record.getUserId().equals(userId)) throw new BusinessException(403, "无权操作");
12.3 全局异常处理器
@Slf4j
@RestControllerAdvice // = @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
// 处理业务异常(已知错误)
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
log.warn("Business exception: code={}, message={}", e.getCode(), e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
// 处理参数校验失败(@Valid 触发)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
FieldError fieldError = e.getBindingResult().getFieldError();
String message = fieldError != null ? fieldError.getDefaultMessage() : "参数校验失败";
return Result.error(400, message);
}
// 处理未知异常(兜底)
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception e) {
log.error("Unexpected error", e);
return Result.error(500, "服务器内部错误");
}
}
知识点:
@RestControllerAdvice是 AOP 切面,拦截所有 Controller 抛出的异常,统一处理后返回标准格式。Controller 中不需要 try-catch 业务异常。
12.4 常用 HTTP 状态码约定
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 | 成功 | 所有成功响应 |
| 400 | 请求错误 | 参数校验失败、业务规则违反 |
| 401 | 未认证 | 用户名密码错误、Token 无效 |
| 403 | 无权限 | 越权访问他人资源 |
| 404 | 资源不存在 | 图书/用户/记录不存在 |
| 409 | 冲突 | ISBN 重复、已收藏、已评论 |
| 500 | 服务器错误 | 未预期的系统异常 |
13. API 文档(SpringDoc)
13.1 SpringDoc 配置
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("智能图书馆 API")
.description("Smart Library - AI Agent based library management system")
.version("1.0.0"))
// 配置 JWT Bearer Token 认证
.addSecurityItem(new SecurityRequirement().addList("Bearer"))
.components(new Components()
.addSecuritySchemes("Bearer", new SecurityScheme()
.name("Bearer")
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}
访问地址:
- Swagger UI:
http://localhost:8080/swagger-ui/index.html - API JSON:
http://localhost:8080/v3/api-docs
13.2 Controller 注解规范
@Tag(name = "图书") // 模块分组标签
@RestController
@RequestMapping("/api/books")
public class BookController {
@Operation(summary = "分页查询图书列表") // 接口说明
@GetMapping
public Result<IPage<BookVO>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "12") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category) {
return Result.success(bookService.listBooks(page, size, keyword, category));
}
}
13.3 DTO / VO 注解规范
// DTO(请求入参)
@Data
@Schema(description = "图书新增/更新请求")
public class BookDTO {
@NotBlank(message = "书名不能为空")
@Schema(description = "书名", requiredMode = Schema.RequiredMode.REQUIRED)
private String title;
@Schema(description = "图书简介") // 可选字段
private String description;
}
// VO(响应出参)
@Data
@Builder
@Schema(description = "图书响应")
public class BookVO {
@Schema(description = "图书ID")
private Long id;
@Schema(description = "当前可借库存")
private Integer stock;
}
13.4 application.yml 中的 SpringDoc 配置
springdoc:
swagger-ui:
path: /swagger-ui/index.html
tags-sorter: alpha # 按字母排序标签
operations-sorter: alpha # 按字母排序接口
api-docs:
path: /v3/api-docs
packages-to-scan: cn.edu.cdu.smartlibrary.controller # 只扫描 controller 包
14. 数据库设计
14.1 数据库表总览
本项目共 8 张表,覆盖用户、图书、借阅、收藏、评论、AI 对话等核心业务。
14.2 建表 SQL
-- 用户表
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`username` varchar(64) NOT NULL UNIQUE,
`password` varchar(255) NOT NULL,
`role` varchar(16) NOT NULL DEFAULT 'READER', -- ADMIN / READER
`email` varchar(128),
`avatar_url` varchar(512),
`created_at` datetime,
`updated_at` datetime,
`deleted` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
);
-- 图书表
CREATE TABLE `book` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`author` varchar(128) NOT NULL,
`isbn` varchar(32) NOT NULL UNIQUE,
`category` varchar(64) NOT NULL,
`description` text,
`cover_url` varchar(512),
`stock` int NOT NULL DEFAULT 0, -- 当前可借库存
`total` int NOT NULL DEFAULT 0, -- 总藏书量
`published_at` date,
`created_at` datetime,
`updated_at` datetime,
`deleted` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
);
-- 借阅记录表
CREATE TABLE `borrow_record` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`book_id` bigint NOT NULL,
`borrow_date` datetime NOT NULL,
`due_date` datetime NOT NULL,
`return_date` datetime,
`status` varchar(16) NOT NULL DEFAULT 'BORROWED', -- BORROWED/RETURNED/OVERDUE
`created_at` datetime,
`updated_at` datetime,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_book_id` (`book_id`)
);
-- 用户收藏表
CREATE TABLE `user_favorite` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`book_id` bigint NOT NULL,
`created_at` datetime,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_book` (`user_id`, `book_id`) -- 防止重复收藏
);
-- 图书评论表
CREATE TABLE `book_review` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`book_id` bigint NOT NULL,
`rating` int NOT NULL, -- 1-5 分
`content` text,
`created_at` datetime,
`updated_at` datetime,
`deleted` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
);
-- AI 对话会话表
CREATE TABLE `ai_chat_session` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`title` varchar(255) NOT NULL DEFAULT '新对话',
`created_at` datetime,
`updated_at` datetime,
`deleted` int NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
);
-- AI 对话消息表
CREATE TABLE `ai_chat_message` (
`id` bigint NOT NULL AUTO_INCREMENT,
`session_id` bigint NOT NULL,
`role` varchar(16) NOT NULL, -- USER / ASSISTANT
`content` longtext NOT NULL,
`sources` text, -- RAG 引用来源 JSON
`created_at` datetime,
PRIMARY KEY (`id`),
KEY `idx_session_id` (`session_id`)
);
-- AI 推荐记录表
CREATE TABLE `ai_recommendation` (
`id` bigint NOT NULL AUTO_INCREMENT,
`user_id` bigint NOT NULL,
`book_id` bigint NOT NULL,
`reason` text,
`score` decimal(3,2),
`source` varchar(16) NOT NULL DEFAULT 'AI', -- AI / HOT
`created_at` datetime,
PRIMARY KEY (`id`)
);
14.3 表关系说明
user ──────────────── borrow_record ──── book
│ │
├──────────────── user_favorite ─────────┤
│ │
├──────────────── book_review ───────────┤
│
├──────────────── ai_chat_session
│ │
│ ai_chat_message
│
└──────────────── ai_recommendation ─── book
15. 前端技术栈
15.1 项目结构
src/
├── api/ # 接口层(一个模块一个文件)
│ ├── auth.ts # 认证接口
│ ├── book.ts # 图书接口
│ ├── borrow.ts # 借阅接口
│ ├── agent.ts # AI 智能体接口
│ ├── recommend.ts # 推荐接口
│ └── dashboard.ts # 看板接口
├── views/ # 页面(子目录用模块名)
│ ├── login/ # 登录页
│ ├── dashboard/ # 数据看板(Admin)
│ ├── book/ # 图书列表 + 详情
│ ├── borrow/ # 借阅记录
│ ├── agent/ # AI 对话(全屏)
│ ├── recommend/ # 推荐页
│ └── admin/ # 管理后台
├── components/ # 公共组件
│ ├── BookCard.vue # 图书卡片
│ ├── MarkdownRenderer.vue # Markdown 渲染(防 XSS)
│ └── ChatBubble.vue # 对话气泡
├── router/modules/ # 路由(按模块拆分)
├── store/modules/ # Pinia 状态管理
└── utils/http/ # Axios 封装
15.2 API 层规范
// src/api/book.ts
import { http } from '@/utils/http'
// 定义响应类型
export interface BookVO {
id: number
title: string
author: string
isbn: string
category: string
description: string
coverUrl: string
stock: number
total: number
}
export interface PageResult<T> {
records: T[]
total: number
pages: number
}
// 封装 API 调用(禁止在组件中直接调用 http)
export const getBooks = (params: {
page?: number
size?: number
keyword?: string
category?: string
}) => http.get<PageResult<BookVO>>('/api/books', { params })
export const getBook = (id: number) =>
http.get<BookVO>(`/api/books/${id}`)
export const createBook = (data: Partial<BookVO>) =>
http.post<BookVO>('/api/books', data)
15.3 Vue 3 Composition API 规范
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getBooks, type BookVO } from '@/api/book'
import { ElMessage } from 'element-plus'
// Props 使用泛型写法
const props = defineProps<{
category?: string
pageSize?: number
}>()
// Emits 使用泛型写法
const emit = defineEmits<{
select: [book: BookVO]
}>()
// 响应式状态
const books = ref<BookVO[]>([])
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
// 加载数据
async function loadBooks() {
loading.value = true
try {
const res = await getBooks({
page: currentPage.value,
size: props.pageSize ?? 12,
category: props.category
})
books.value = res.data.records
total.value = res.data.total
} catch (err) {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
onMounted(() => loadBooks())
</script>
15.4 路由配置规范
// src/router/modules/book.ts
export default [
{
path: '/books',
name: 'BookList',
component: () => import('@/views/book/index.vue'),
meta: {
title: '图书列表',
roles: ['admin', 'reader'] // 两种角色都可访问
}
},
{
path: '/books/:id',
name: 'BookDetail',
component: () => import('@/views/book/detail.vue'),
meta: { title: '图书详情', roles: ['admin', 'reader'] }
}
]
// AI 对话页:全屏布局,隐藏侧边栏和导航栏
{
path: '/chat',
name: 'AgentChat',
component: () => import('@/views/agent/index.vue'),
meta: {
title: 'AI 书灵',
roles: ['admin', 'reader'],
hiddenSideBar: true, // 隐藏侧边栏
hiddenNavBar: true // 隐藏导航栏
}
}
15.5 Pinia 状态管理
// src/store/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const username = ref('')
const role = ref('')
const isAdmin = computed(() => role.value === 'ADMIN')
const isLoggedIn = computed(() => !!token.value)
function setToken(newToken: string) {
token.value = newToken
localStorage.setItem('token', newToken)
}
function logout() {
token.value = ''
username.value = ''
role.value = ''
localStorage.removeItem('token')
}
return { token, username, role, isAdmin, isLoggedIn, setToken, logout }
})
15.6 Axios 封装(HTTP 请求规范)
// src/utils/http/index.ts
import axios from 'axios'
import { useUserStore } from '@/store/modules/user'
import router from '@/router'
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000
})
// 请求拦截器:注入 JWT Token
http.interceptors.request.use(config => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
// 响应拦截器:统一处理错误
http.interceptors.response.use(
response => response.data, // 直接返回 data,省去 .data.data
error => {
if (error.response?.status === 401) {
// Token 失效,清除并跳转登录
useUserStore().logout()
router.push('/login')
}
return Promise.reject(error)
}
)
export { http }
16. 部署与配置
16.1 环境准备
| 依赖 | 版本要求 | 说明 |
|---|---|---|
| JDK | 17+ | 后端运行环境 |
| Node.js | 20.19+ | 前端构建环境 |
| MySQL | 8.x | 主数据库 |
| Redis | 7.x | 缓存服务 |
| Ollama | 最新版 | 本地 LLM 服务 |
| pnpm | 最新版 | 前端包管理器 |
16.2 Ollama 模型安装
# 安装 Ollama(macOS/Linux)
curl -fsSL https://ollama.ai/install.sh | sh
# 拉取对话模型(约 2GB)
ollama pull qwen2.5:3b
# 拉取嵌入模型(约 274MB)
ollama pull nomic-embed-text
# 验证服务
curl http://localhost:11434/api/tags
16.3 数据库初始化
# 创建数据库
mysql -u root -p -e "CREATE DATABASE smart_library CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# 执行建表 SQL(参考第 14 章)
mysql -u root -p smart_library < schema.sql
16.4 后端启动
cd smart-library/smart-library
# 开发模式运行
./mvnw spring-boot:run
# 打包 JAR
./mvnw clean package -DskipTests
# 运行 JAR
java -jar target/smart-library-0.0.1-SNAPSHOT.jar
16.5 前端启动
cd smart-library/frontend
# 安装依赖
pnpm install
# 开发模式(localhost:5173)
pnpm dev
# 生产构建
pnpm build
# 类型检查
pnpm typecheck
16.6 环境变量配置
# .env.development(开发环境)
VITE_API_BASE_URL=http://localhost:8080
# .env.production(生产环境)
VITE_API_BASE_URL=https://your-domain.com
16.7 Docker 部署(参考)
# 后端 Dockerfile
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY target/smart-library-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
16.8 生产环境安全配置
在生产环境中,需要修改 SecurityConfig 中的权限规则:
// 将开发环境的 anyRequest().permitAll() 替换为:
.requestMatchers("/api/auth/login", "/api/auth/register").permitAll()
.requestMatchers(HttpMethod.GET, "/api/books", "/api/books/{id}").permitAll()
.requestMatchers("/uploads/**").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/admin/**", "/api/dashboard/**").hasRole("ADMIN")
.anyRequest().authenticated()
同时修改 application.yml 中的 JWT 密钥:
app:
jwt:
# 生产环境使用强随机密钥(至少 32 字符)
secret: ${JWT_SECRET} # 从环境变量读取,不硬编码
expiration: 86400000
附录 A:完整 API 接口清单
A.1 认证模块
| 方法 | 路径 | 权限 | 请求体 | 响应 |
|---|---|---|---|---|
| POST | /api/auth/login |
公开 | LoginDTO |
LoginVO |
| POST | /api/auth/register |
公开 | RegisterDTO |
- |
| POST | /api/auth/register/admin |
公开 | RegisterDTO |
- |
| POST | /api/auth/logout |
登录 | - | - |
| GET | /api/auth/me |
登录 | - | UserVO |
| PUT | /api/auth/password |
登录 | ChangePasswordDTO |
- |
A.2 图书模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/books |
公开 | 分页查询(keyword, category, page, size) |
| GET | /api/books/{id} |
公开 | 获取详情 |
| POST | /api/books |
ADMIN | 新增图书 |
| PUT | /api/books/{id} |
ADMIN | 更新图书 |
| DELETE | /api/books/{id} |
ADMIN | 逻辑删除 |
| POST | /api/books/reindex |
ADMIN | 重建向量索引 |
| GET | /api/books/search/semantic |
公开 | 语义搜索(q, page, size) |
A.3 借阅模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/borrows |
登录 | 借阅图书(bookId) |
| PUT | /api/borrows/{id}/return |
登录 | 归还图书 |
| GET | /api/borrows/my |
登录 | 我的借阅记录(page, size) |
| GET | /api/borrows |
ADMIN | 全量记录(page, size, status) |
A.4 收藏模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/favorites/{bookId} |
登录 | 收藏图书 |
| DELETE | /api/favorites/{bookId} |
登录 | 取消收藏 |
| GET | /api/favorites/my |
登录 | 我的收藏列表 |
| GET | /api/favorites/check/{bookId} |
登录 | 检查是否已收藏 |
A.5 评论模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/reviews |
登录 | 提交评论(bookId, rating, content) |
| GET | /api/reviews/book/{bookId} |
公开 | 图书评论列表 |
| GET | /api/reviews/book/{bookId}/rating |
公开 | 图书平均评分 |
A.6 AI 智能体模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/agent/sessions |
登录 | 创建会话 |
| GET | /api/agent/sessions |
登录 | 会话列表 |
| DELETE | /api/agent/sessions/{id} |
登录 | 删除会话 |
| GET | /api/agent/sessions/{id}/messages |
登录 | 消息历史 |
| POST | /api/agent/chat |
登录 | 发送消息(JSON) |
| GET | /api/agent/chat/stream |
登录 | 发送消息(SSE) |
A.7 推荐模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/recommend |
登录 | 获取个性化推荐 |
A.8 数据看板模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/dashboard/overview |
ADMIN | 总览统计 |
| GET | /api/dashboard/borrow-trend |
ADMIN | 30天借阅趋势 |
| GET | /api/dashboard/top-books |
ADMIN | 热门图书 Top10 |
| GET | /api/dashboard/category-stats |
ADMIN | 分类借阅统计 |
| GET | /api/dashboard/ai-stats |
ADMIN | AI 对话统计 |
A.9 文件模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/files/upload/cover |
ADMIN | 上传图书封面 |
| POST | /api/files/upload/avatar |
登录 | 上传用户头像 |
A.10 用户管理模块
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/admin/users |
ADMIN | 分页查询用户(keyword, page, size) |
| PUT | /api/admin/users/{id}/status |
ADMIN | 启用/禁用用户 |
附录 B:核心设计模式与最佳实践
B.1 Builder 模式(VO 构建)
// 使用 @Builder 注解,链式调用构建 VO
return BorrowVO.builder()
.id(r.getId())
.bookId(r.getBookId())
.bookTitle(book != null ? book.getTitle() : "")
.borrowDate(r.getBorrowDate())
.dueDate(r.getDueDate())
.status(r.getStatus())
.build();
优势:避免大量 setter 调用,代码更清晰;字段可选,不需要的字段不设置。
B.2 策略模式(降级策略)
// 语义搜索降级为关键词搜索
List<Long> semanticIds = vectorStoreService.semanticSearch(query, 10);
boolean degraded = semanticIds.isEmpty(); // 是否降级
// 推荐降级为热门列表
if (borrows.isEmpty()) {
result = getHotRecommendations(); // 无历史 → 热门
} else {
result = getAiRecommendations(userId, borrows); // 有历史 → AI 推荐
}
B.3 防御性编程
// 工具方法必须捕获所有异常,返回 error 字段
@Tool(description = "搜索图书")
public String searchBooks(String keyword, String category) {
try {
// ... 业务逻辑
} catch (Exception e) {
log.warn("searchBooks error: {}", e.getMessage());
return toJson(Map.of("error", e.getMessage())); // 不抛出,返回错误信息
}
}
// 向量化失败不影响主流程
public void indexBook(Book book) {
try {
// ... 向量化逻辑
} catch (Exception e) {
log.warn("Failed to index book {}: {}", book.getId(), e.getMessage());
// 静默失败,不影响图书 CRUD
}
}
B.4 乐观锁防并发
// 不使用悲观锁(SELECT FOR UPDATE),而是用条件更新
// 多个请求同时借阅同一本书时,只有一个能成功
@Update("UPDATE book SET stock = stock - 1 WHERE id = #{bookId} AND stock > 0")
int decreaseStock(@Param("bookId") Long bookId);
// 返回 0 表示库存不足(或被其他请求抢先借走)
int affected = borrowMapper.decreaseStock(bookId);
if (affected == 0) throw new BusinessException(400, "库存不足");
B.5 函数式编程(Java 8+)
// Stream API 处理集合
List<RecommendVO> result = hotIds.stream()
.map(id -> {
try {
BookVO book = bookService.getBook(id);
return RecommendVO.builder().book(book).source("HOT").build();
} catch (Exception e) {
return null;
}
})
.filter(r -> r != null) // 过滤掉 null
.toList();
// Collectors.groupingBy 分组统计
Map<Long, Long> countMap = all.stream()
.collect(Collectors.groupingBy(BorrowRecord::getBookId, Collectors.counting()));
// Collectors.joining 字符串拼接
String preference = borrows.stream()
.map(b -> book.getTitle() + "(" + book.getCategory() + ")")
.collect(Collectors.joining("、"));
附录 C:常见问题与解决方案
C.1 Ollama 不可用时的降级处理
问题:Ollama 服务未启动或网络不通,AI 功能报错。
解决:所有 AI 相关调用都包裹在 try-catch 中,失败时返回降级结果:
- 语义搜索 → 关键词搜索
- AI 推荐 → 热门 Top10
- AI 对话 → 返回"服务暂时不可用"提示
C.2 SSE 连接中 SecurityContext 丢失
问题:SSE 使用独立线程池,SecurityContextHolder(ThreadLocal)在线程切换后丢失。
解决:在 Tomcat 线程中捕获 SecurityContext,传入子线程并手动设置:
SecurityContext securityContext = SecurityContextHolder.getContext();
sseExecutor.execute(() -> {
SecurityContextHolder.setContext(securityContext);
try { /* ... */ } finally {
SecurityContextHolder.clearContext();
}
});
C.3 Spring AI Tool Calling 后流式响应丢失
问题:Spring AI M6 的 stream().content() 只捕获第一轮文本流,Tool Calling 后的第二轮回答丢失。
解决:改用 call().content() 获取完整答案,再将答案按字符逐个推送为 Flux<String>,模拟流式效果。
C.4 MyBatis-Plus 3.5.9 分页插件报错
问题:PaginationInnerInterceptor 依赖 JSqlParser,3.5.9 版本需要单独引入。
解决:在 pom.xml 中添加:
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.9</version>
</dependency>
C.5 nomic-embed-text 超长输入报错
问题:nomic-embed-text 上下文窗口约 8192 tokens,超长文本会返回 400 错误。
解决:向量化前截断文本到 500 字符:
String query = message.length() > 500 ? message.substring(0, 500) : message;
C.6 LLM 历史消息污染工具调用
问题:LLM 有时会将工具调用以 JSON 文本形式输出到 ASSISTANT 消息,下次加载历史时会干扰模型行为。
解决:过滤掉包含工具调用文本的历史消息:
private boolean isCorruptedToolCallText(String content) {
return content.contains("\"name\"") && content.contains("\"arguments\"")
&& (content.contains("searchBooks") || content.contains("borrowBook") /* ... */);
}
附录 D:学习路线建议
D.1 后端学习路线
1. Java 基础 → 面向对象、集合、泛型、Lambda、Stream API
2. Spring Boot → 自动配置、依赖注入、AOP、事务
3. Spring Security → 认证授权、JWT、过滤器链
4. MyBatis-Plus → CRUD、条件构造器、分页、逻辑删除
5. Redis → 数据结构、缓存模式、TTL
6. Spring AI → ChatClient、Tool Calling、RAG、向量存储
7. 项目实战 → 阅读本项目源码,理解各模块协作
D.2 前端学习路线
1. HTML/CSS/JavaScript 基础
2. TypeScript → 类型系统、接口、泛型
3. Vue 3 → Composition API、响应式、生命周期
4. Vue Router → 路由配置、导航守卫、动态路由
5. Pinia → 状态管理、持久化
6. Element Plus → 表单、表格、弹窗组件
7. Axios → HTTP 请求、拦截器
8. ECharts → 图表配置、数据格式
D.3 推荐阅读顺序(本项目源码)
application.yml→ 了解配置entity/→ 了解数据模型common/Result.java+GlobalExceptionHandler.java→ 了解响应规范config/SecurityConfig.java+JwtAuthenticationFilter.java→ 了解认证流程controller/AuthController.java+service/impl/AuthServiceImpl.java→ 了解登录注册controller/BookController.java+service/impl/BookServiceImpl.java→ 了解 CRUDservice/impl/BorrowServiceImpl.java→ 了解事务和乐观锁agent/LibraryAgent.java+agent/AgentTools.java→ 了解 AI 智能体service/impl/VectorStoreServiceImpl.java→ 了解 RAGcontroller/AgentController.java→ 了解 SSE 流式响应
更多推荐

所有评论(0)