智能图书馆系统(Smart Library)技术学习文档

本文档基于项目实际源码,系统讲解后端各技术栈的知识点与业务实现,适合学习 Spring Boot 全栈开发。


目录

  1. 项目概览
  2. 技术栈总览
  3. 项目结构详解
  4. Spring Boot 核心知识
  5. Spring Security + JWT 认证
  6. MyBatis-Plus ORM 框架
  7. Redis 缓存应用
  8. Spring AI 智能体
  9. RAG 检索增强生成
  10. SSE 流式响应
  11. 业务模块详解
  12. 统一响应与异常处理
  13. API 文档(SpringDoc)
  14. 数据库设计
  15. 前端技术栈
  16. 部署与配置

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 开启逻辑删除后,selectByIdselectList 等查询会自动加上 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 推荐阅读顺序(本项目源码)

  1. application.yml → 了解配置
  2. entity/ → 了解数据模型
  3. common/Result.java + GlobalExceptionHandler.java → 了解响应规范
  4. config/SecurityConfig.java + JwtAuthenticationFilter.java → 了解认证流程
  5. controller/AuthController.java + service/impl/AuthServiceImpl.java → 了解登录注册
  6. controller/BookController.java + service/impl/BookServiceImpl.java → 了解 CRUD
  7. service/impl/BorrowServiceImpl.java → 了解事务和乐观锁
  8. agent/LibraryAgent.java + agent/AgentTools.java → 了解 AI 智能体
  9. service/impl/VectorStoreServiceImpl.java → 了解 RAG
  10. controller/AgentController.java → 了解 SSE 流式响应

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐