DocuMind AI 技术学习文档

项目:DocuMind AI 智能文档问答系统
技术栈:Spring Boot 3.3.5 + Spring AI Alibaba + MyBatis-Plus + Spring Security + JWT + Redis + MySQL
作者:DocuMind Team


目录

  1. 项目概述
  2. 技术栈总览
  3. Spring Boot 3.x 核心知识
  4. Spring Security + JWT 认证体系
  5. MyBatis-Plus 数据访问层
  6. Spring AI Alibaba 与大模型集成
  7. RAG 检索增强生成
  8. 向量存储与文档处理
  9. 流式响应与 SSE
  10. AOP 切面编程
  11. 异步任务与事务管理
  12. 全局异常处理
  13. 数据库设计
  14. 业务模块详解
  15. API 接口设计规范
  16. 配置管理与多环境
  17. Lombok 使用指南
  18. Redis 缓存集成
  19. 文件上传与处理
  20. 项目架构与设计模式

1. 项目概述

1.1 系统简介

DocuMind AI 是一个智能文档问答系统,核心理念是"我的专属 AI"。每个用户可以创建多个智能体,为每个智能体上传专属文档,AI 基于这些文档回答问题。

1.2 核心功能

  • 智能体管理:用户创建、配置、管理多个 AI 智能体
  • 文档知识库:上传 PDF/Word/Excel/TXT/MD 文档,自动向量化
  • RAG 问答:基于文档内容进行检索增强生成,回答更准确
  • 多提示词模式:根据用户问题自动切换最合适的系统提示词
  • 流式对话:SSE 实时流式输出 AI 回复
  • 对话历史:完整保存每次对话,支持多会话管理
  • API 监控:AOP 切面自动记录所有接口调用日志

1.3 技术架构图

前端请求
    ↓
Spring Security 过滤链(JWT 验证)
    ↓
Controller 层(REST API)
    ↓
Service 层(业务逻辑)
    ↓
├── MyBatis-Plus(MySQL 数据持久化)
├── Spring AI(大模型调用)
├── VectorStore(向量检索)
└── Redis(缓存)

1.4 项目包结构

cn.edu.cdu.documind
├── aspect/          # AOP 切面(API 监控)
├── common/          # 公共类(统一响应 Result)
├── config/          # 配置类
├── controller/      # 控制器层
├── dto/             # 数据传输对象
│   ├── request/     # 请求 DTO
│   └── response/    # 响应 DTO
├── entity/          # 实体类(对应数据库表)
├── exception/       # 全局异常处理
├── mapper/          # MyBatis-Plus Mapper
├── reader/          # 文档读取器
├── security/        # 安全相关(JWT 过滤器)
├── service/         # 业务逻辑层
└── util/            # 工具类

2. 技术栈总览

2.1 核心依赖清单

技术 版本 用途
Spring Boot 3.3.5 基础框架
Spring AI Alibaba 1.0.0.2 大模型集成
MyBatis-Plus 3.5.11 ORM 框架
Spring Security 随 Boot 认证授权
JJWT 0.12.5 JWT 令牌
MySQL 8.x 主数据库
Redis 7.x 缓存
PDFBox 3.0.1 PDF 解析
Apache POI 5.2.5 Excel/Word 解析
Springdoc OpenAPI 2.3.0 Swagger 文档
Lombok 1.18.30 代码简化
Java 17 运行环境

2.2 pom.xml 关键配置解析

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
</parent>

知识点spring-boot-starter-parent 是 Spring Boot 的父 POM,它做了三件事:

  1. 统一管理常用依赖版本(无需写 <version>
  2. 配置 Maven 插件默认值
  3. 设置资源过滤规则

2.3 Spring Boot 3.x 与 2.x 的主要区别

特性 Spring Boot 2.x Spring Boot 3.x
Java 最低版本 Java 8 Java 17
Jakarta EE javax.* jakarta.*
Spring Security WebSecurityConfigurerAdapter SecurityFilterChain Bean
AOT 编译 不支持 支持 GraalVM

重要:本项目使用 jakarta.servlet.* 而非 javax.servlet.*,这是 Spring Boot 3.x 的重大变化。


3. Spring Boot 3.x 核心知识

3.1 自动配置原理

Spring Boot 的核心是自动配置(Auto-Configuration)。当你引入一个 Starter 依赖,Spring Boot 会自动配置相关 Bean。

原理链路

@SpringBootApplication
    └── @EnableAutoConfiguration
            └── AutoConfigurationImportSelector
                    └── 读取 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
                            └── 按条件注册 Bean(@ConditionalOnClass、@ConditionalOnMissingBean 等)

项目入口

// DocumindApplication.java
@SpringBootApplication
public class DocumindApplication {
    public static void main(String[] args) {
        SpringApplication.run(DocumindApplication.class, args);
    }
}

3.2 常用注解详解

@Configuration

标记一个类为配置类,等价于 XML 中的 <beans>

@Configuration
public class VectorStoreConfig {
    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }
}

知识点@Bean 方法的参数会自动从 Spring 容器中注入,这叫方法参数注入

@Value 注入配置
@Value("${documind.vector.store.path:./data/vector-store.json}")
private String vectorStorePath;

语法:${配置键:默认值},冒号后面是默认值,当配置文件中没有该键时使用。

@Component 系列注解
注解 语义 使用场景
@Component 通用组件 工具类、辅助类
@Service 业务服务 Service 层
@Repository 数据访问 DAO 层(MyBatis-Plus 用 @Mapper)
@Controller MVC 控制器 返回视图
@RestController REST 控制器 返回 JSON,= @Controller + @ResponseBody

3.3 依赖注入方式对比

项目中大量使用构造器注入(推荐方式):

@Service
@RequiredArgsConstructor  // Lombok 生成构造器
public class ChatService {
    private final AgentMapper agentMapper;      // final 字段
    private final ChatHistoryService historyService;
    // Lombok 自动生成包含所有 final 字段的构造器
}

三种注入方式对比

方式 写法 优缺点
字段注入 @Autowired private XxxService xxx 简洁但不推荐,无法做 null 检查
Setter 注入 @Autowired public void setXxx(...) 可选依赖时使用
构造器注入 @RequiredArgsConstructor + final 推荐,依赖不可变,便于测试

4. Spring Security + JWT 认证体系

4.1 整体认证流程

客户端请求
    ↓
JwtAuthenticationFilter(OncePerRequestFilter)
    ↓ 提取并验证 JWT
SecurityContextHolder.setAuthentication(authToken)
    ↓
SecurityFilterChain 授权检查
    ↓
Controller 处理请求

4.2 SecurityConfig 详解

@Configuration
@EnableWebSecurity
@EnableMethodSecurity   // 开启方法级别安全(@PreAuthorize 等)
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)  // 禁用 CSRF(JWT 无状态不需要)
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()  // 白名单
                .anyRequest().authenticated()                 // 其余需认证
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)  // 无状态
            )
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);  // 插入 JWT 过滤器
        return http.build();
    }
}

知识点

  • SessionCreationPolicy.STATELESS:不创建 Session,每次请求都通过 JWT 验证
  • addFilterBefore:将 JWT 过滤器插入到默认用户名密码过滤器之前

4.3 JWT 工具类详解

JWT(JSON Web Token)由三部分组成:Header.Payload.Signature

// 生成 Token
public String generateToken(Long userId, String username) {
    Map<String, Object> claims = new HashMap<>();
    claims.put("userId", userId);   // 自定义载荷:存入 userId
    return Jwts.builder()
            .claims(claims)
            .subject(username)      // 标准载荷:sub(主题)
            .issuedAt(new Date())   // 标准载荷:iat(签发时间)
            .expiration(expiryDate) // 标准载荷:exp(过期时间)
            .signWith(getSigningKey(), SignatureAlgorithm.HS256)
            .compact();
}

JWT 三部分

  • Header:算法类型(HS256)和令牌类型(JWT)
  • Payload:载荷,存放用户信息(userId、username、过期时间等)
  • Signature:签名,防止篡改

4.4 JWT 过滤器实现

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain) {

        String authHeader = request.getHeader("Authorization");
        // Bearer token 格式:Authorization: Bearer eyJhbGci...
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String jwt = authHeader.substring(7);  // 去掉 "Bearer " 前缀
        String username = jwtUtil.extractUsername(jwt);
        Long userId = jwtUtil.extractUserId(jwt);

        if (username != null && SecurityContextHolder.getContext()
                .getAuthentication() == null) {
            if (jwtUtil.validateToken(jwt, username)) {
                // 构建认证对象,放入 SecurityContext
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}

优化点:本项目直接从 JWT 中提取 userId,无需查询数据库,减少了每次请求的数据库压力。

4.5 CORS 跨域配置

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOriginPatterns(List.of("*"));  // 允许所有源
    configuration.setAllowedMethods(Arrays.asList(
        "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
    configuration.setAllowCredentials(true);  // 允许携带 Cookie
    configuration.setMaxAge(3600L);           // 预检请求缓存 1 小时
    // ...
}

知识点setAllowedOriginPatterns("*")setAllowedOrigins("*") 的区别:前者支持 allowCredentials(true),后者不支持。


5. MyBatis-Plus 数据访问层

5.1 MyBatis-Plus 简介

MyBatis-Plus(MP)是 MyBatis 的增强工具,在 MyBatis 基础上只做增强不做改变,提供了大量开箱即用的 CRUD 方法。

5.2 实体类注解

@TableName("agent")          // 对应数据库表名
@Data                        // Lombok:生成 getter/setter/toString/equals/hashCode
@NoArgsConstructor           // Lombok:无参构造器
@AllArgsConstructor          // Lombok:全参构造器
public class Agent {

    @TableId(value = "id", type = IdType.AUTO)  // 主键,自增
    private Long id;

    @TableField("user_id")   // 对应数据库列名(驼峰自动映射可省略)
    private Long userId;

    @TableField("system_prompt")
    private String systemPrompt;
}

IdType 枚举

说明
AUTO 数据库自增
ASSIGN_ID 雪花算法生成 Long 型 ID
ASSIGN_UUID UUID
INPUT 手动输入

5.3 Mapper 接口

@Mapper
public interface AgentMapper extends BaseMapper<Agent> {
    // BaseMapper 已提供:insert、deleteById、updateById、selectById
    // selectList、selectPage、selectCount 等基础方法

    // 自定义方法(需要在 XML 或用注解实现)
    void incrementDocumentCount(Long agentId);
    void decrementDocumentCount(Long agentId);
}

BaseMapper 常用方法

// 插入
int insert(T entity);

// 根据 ID 删除
int deleteById(Serializable id);

// 根据条件删除
int delete(Wrapper<T> queryWrapper);

// 根据 ID 更新
int updateById(T entity);

// 根据 ID 查询
T selectById(Serializable id);

// 条件查询单条
T selectOne(Wrapper<T> queryWrapper);

// 条件查询列表
List<T> selectList(Wrapper<T> queryWrapper);

// 分页查询
IPage<T> selectPage(IPage<T> page, Wrapper<T> queryWrapper);

// 统计数量
Long selectCount(Wrapper<T> queryWrapper);

5.4 LambdaQueryWrapper 条件构造器

// 等值查询
new LambdaQueryWrapper<Agent>()
    .eq(Agent::getUserId, userId)           // WHERE user_id = ?

// 模糊查询
.like(Agent::getName, keyword)              // WHERE name LIKE '%keyword%'

// 多条件 OR
.and(w -> w.like(Agent::getName, keyword)
           .or()
           .like(Agent::getRoleName, keyword))

// 排序
.orderByDesc(Agent::getCreatedAt)           // ORDER BY created_at DESC

// 不等于
.ne(User::getId, user.getId())              // WHERE id != ?

为什么用 Lambda 方式:避免硬编码字段名字符串,重构时编译器能检查错误。

5.5 分页插件配置

@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        PaginationInnerInterceptor paginationInterceptor =
            new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setMaxLimit(500L);   // 单页最大 500 条
        interceptor.addInnerInterceptor(paginationInterceptor);
        return interceptor;
    }
}

使用分页

// 创建分页对象:第 page 页,每页 pageSize 条
Page<Agent> pageParam = new Page<>(page, pageSize);
IPage<Agent> result = agentMapper.selectPage(pageParam, wrapper);

result.getRecords();  // 当前页数据
result.getTotal();    // 总记录数
result.getPages();    // 总页数
result.getCurrent();  // 当前页码

6. Spring AI Alibaba 与大模型集成

6.1 Spring AI 核心概念

Spring AI 是 Spring 官方的 AI 集成框架,提供统一的 API 对接各大 AI 服务商。

核心抽象

接口 说明
ChatModel 对话模型接口
EmbeddingModel 向量嵌入模型接口
ChatClient 高级对话客户端(推荐使用)
VectorStore 向量存储接口

6.2 DashScope 配置

# application-dev.yml
spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}  # 从环境变量读取
      chat:
        options:
          model: qwen-plus
          temperature: 0.7
      embedding:
        options:
          model: text-embedding-v2   # 向量化模型

通义千问模型对比

模型 特点 适用场景
qwen-turbo 速度最快,成本最低 简单问答
qwen-plus 均衡,项目默认 通用场景
qwen-max 能力最强,成本最高 复杂推理

6.3 ChatClient 使用方式

// 注入 ChatClient.Builder(Spring AI 自动配置)
private final ChatClient.Builder chatClientBuilder;

// 同步调用
ChatClient chatClient = chatClientBuilder.build();
ChatResponse response = chatClient.prompt(prompt).call().chatResponse();
String text = response.getResult().getOutput().getText();

// 流式调用(返回 Flux<String>)
Flux<String> stream = chatClient.prompt(prompt).stream().content();

6.4 动态参数配置

项目中每个智能体可以独立配置 AI 参数:

DashScopeChatOptions chatOptions = DashScopeChatOptions.builder()
    .withModel(agent.getModelName())                       // 动态模型
    .withTemperature(agent.getTemperature().doubleValue()) // 动态温度
    .withTopP(agent.getTopP().doubleValue())               // 动态 TopP
    .build();

Prompt prompt = new Prompt(messages, chatOptions);

参数说明

  • temperature(温度):0~1,越高回复越随机有创意,越低越确定保守
  • top_p:核采样,控制词汇多样性,通常与 temperature 二选一调整
  • max_tokens:最大输出 token 数,控制回复长度

6.5 消息类型

List<Message> messages = new ArrayList<>();

// 系统消息:定义 AI 角色和行为规则
messages.add(new SystemMessage("你是一个专业的医生..."));

// 用户消息:用户输入
messages.add(new UserMessage("我最近头疼怎么办?"));

// 助手消息:AI 历史回复(用于多轮对话上下文)
messages.add(new AssistantMessage("建议您多休息..."));

多轮对话原理:大模型本身无记忆,每次请求都需要把历史对话一起发送,模型才能"记住"上下文。

6.6 Token 消耗统计

// 同步调用获取 token 信息
ChatResponse response = chatClient.prompt(prompt).call().chatResponse();
Integer totalTokens = response.getMetadata().getUsage().getTotalTokens();

// 流式调用无法直接获取 token,项目中用估算方式
int estimatedTokens = (int) (completeReply.length() * 0.7);
// 估算规则:中文约 1.5 字符/token,英文约 4 字符/token

7. RAG 检索增强生成

7.1 什么是 RAG

RAG(Retrieval-Augmented Generation,检索增强生成)是一种将知识库检索大模型生成结合的技术。

解决的问题

  • 大模型训练数据有截止日期,不了解最新信息
  • 大模型不了解用户的私有文档
  • 大模型可能"幻觉"(编造不存在的信息)

RAG 流程

用户问题
    ↓
向量化问题(Embedding)
    ↓
在向量库中检索相似文档片段(Top-K)
    ↓
将文档片段 + 用户问题组合成 Prompt
    ↓
发送给大模型生成回答
    ↓
返回基于文档的准确回答

7.2 项目中的 RAG 实现

// ChatService.chatStream() 中的 RAG 逻辑
if (agent.getRagEnabled() == 1) {
    List<Document> relevantDocs = vectorService.searchRelevantDocuments(
        userMessage,
        agentId,
        agent.getRagTopK(),                              // 检索 Top-K 个片段
        agent.getRagSimilarityThreshold().doubleValue()  // 相似度阈值
    );

    if (!relevantDocs.isEmpty()) {
        StringBuilder contextBuilder = new StringBuilder("\n\n【参考文档】\n");
        for (int i = 0; i < relevantDocs.size(); i++) {
            Document doc = relevantDocs.get(i);
            contextBuilder.append(String.format("%d. 来源:%s\n内容:%s\n\n",
                i + 1,
                doc.getMetadata().get("file_name"),
                doc.getText()
            ));
        }
        // 将检索到的文档内容追加到用户消息后面
        finalMessage = userMessage + contextBuilder.toString();
    }
}

7.3 RAG 参数调优

参数 项目默认值 说明
rag_top_k 10 检索最相关的 10 个文档片段
rag_similarity_threshold 0.50 相似度低于 0.5 的片段不返回

调优建议

  • top_k 越大,召回越全面,但 Prompt 越长,成本越高
  • similarity_threshold 越高,精度越高,但可能漏掉相关内容
  • 通常先调低阈值(0.3~0.5)确保召回,再根据效果调整

7.4 数据隔离设计

项目中多个智能体共用一个向量存储,通过 agent_id 实现数据隔离:

// 向量化时写入 agent_id
metadata.put("agent_id", document.getAgentId().toString());

// 检索时手动过滤
List<Document> filteredResults = allResults.stream()
    .filter(doc -> {
        Object agentIdObj = doc.getMetadata().get("agent_id");
        return agentIdObj != null &&
               agentIdObj.toString().equals(agentId.toString());
    })
    .limit(topK)
    .toList();

注意:SimpleVectorStore 不支持原生 metadata 过滤,所以先多查(topK * 2),再手动过滤。生产环境建议升级到 Qdrant 等支持过滤的向量数据库。


8. 向量存储与文档处理

8.1 向量化原理

向量(Embedding):将文本转换为高维数值向量,语义相近的文本在向量空间中距离更近。

"我头疼" → [0.12, -0.34, 0.89, ...]  (1536 维向量)
"头部疼痛" → [0.11, -0.33, 0.91, ...]  (距离很近,语义相似)
"今天天气好" → [-0.45, 0.67, -0.23, ...] (距离很远,语义不同)

8.2 文档向量化流程

@Async  // 异步执行,不阻塞上传接口
public void vectorizeDocument(Long documentId) {
    // 1. 读取文件
    DocumentReader reader;
    if ("xls".equals(fileType) || "xlsx".equals(fileType)) {
        reader = new ExcelReader(new FileSystemResource(file));
    } else {
        reader = new TextReader(new FileSystemResource(file));
    }
    List<Document> documents = reader.get();

    // 2. 文档分块(Chunking)
    TokenTextSplitter splitter = new TokenTextSplitter(
        500,    // chunkSize:每块最大 500 token
        50,     // chunkOverlap:相邻块重叠 50 token(保持上下文连贯)
        5,      // minChunkSizeChars:最小块字符数
        10000,  // maxChunkSizeChars:最大块字符数
        true    // keepSeparator:保留分隔符
    );
    List<Document> chunks = splitter.apply(documents);

    // 3. 添加 metadata
    for (int i = 0; i < chunks.size(); i++) {
        chunks.get(i).getMetadata().put("agent_id", agentId.toString());
        chunks.get(i).getMetadata().put("file_name", fileName);
        chunks.get(i).getMetadata().put("chunk_index", i);
    }

    // 4. 存入向量库(自动调用 EmbeddingModel 生成向量)
    vectorStore.add(chunks);

    // 5. 持久化到文件
    simpleVectorStore.save(vectorFile);
}

分块(Chunking)的重要性

  • 文档太长无法直接向量化(有 token 限制)
  • 分块后每块语义更集中,检索更精准
  • 重叠(overlap)确保跨块的信息不丢失

8.3 SimpleVectorStore 配置

@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
    SimpleVectorStore vectorStore = SimpleVectorStore.builder(embeddingModel).build();

    File vectorFile = new File(vectorStorePath);
    if (vectorFile.exists()) {
        vectorStore.load(vectorFile);  // 启动时从文件加载已有向量
    }
    return vectorStore;
}

SimpleVectorStore 特点

  • 数据存储在内存中,重启后从 JSON 文件恢复
  • 相似度搜索使用余弦相似度
  • 适合开发和小规模应用(< 10 万条向量)
  • 生产环境建议使用 Qdrant、Milvus、Pinecone 等

8.4 向量状态机

文档向量化有 4 个状态:

0(待处理)→ 1(处理中)→ 2(已完成)
                        ↘ 3(失败)
// 状态常量
document.setVectorStatus(0);  // 待处理(上传后初始状态)
document.setVectorStatus(1);  // 处理中(开始向量化)
document.setVectorStatus(2);  // 已完成(向量化成功)
document.setVectorStatus(3);  // 失败(记录错误信息)

8.5 并发安全处理

多个文档同时向量化时,保存向量文件需要加锁:

private final Object saveLock = new Object();  // 对象锁

synchronized (saveLock) {  // 同一时刻只有一个线程保存文件
    simpleVectorStore.save(vectorFile);
}

9. 流式响应与 SSE

9.1 什么是 SSE

SSE(Server-Sent Events,服务器推送事件)是一种服务器向客户端单向推送数据的技术。

与 WebSocket 对比

特性 SSE WebSocket
方向 单向(服务器→客户端) 双向
协议 HTTP WS
断线重连 自动 需手动
适用场景 AI 流式输出、通知 实时聊天、游戏

9.2 项目中的流式实现

// Controller:声明返回 SSE 格式
@PostMapping(value = "/stream",
    produces = MediaType.TEXT_EVENT_STREAM_VALUE)  // 关键:声明 SSE
public Flux<String> sendMessageStream(
        @RequestBody ChatRequest request,
        Authentication authentication) {
    return chatService.chatStream(...);
}
// Service:返回 Reactor Flux
public Flux<String> chatStream(...) {
    return chatClient.prompt(prompt)
        .stream()
        .content()                    // 每个 token 作为一个事件
        .doOnNext(chunk -> {
            fullReply.append(chunk);  // 收集完整回复
        })
        .doOnComplete(() -> {
            // 流结束后保存到数据库
            historyService.saveMessage(..., fullReply.toString(), ...);
        })
        .doOnError(error -> {
            // 错误处理
        });
}

9.3 Project Reactor 基础

Spring WebFlux 使用 Project Reactor 实现响应式编程:

类型 说明 类比
Mono<T> 0 或 1 个元素的异步序列 Optional
Flux<T> 0 到 N 个元素的异步序列 Stream

常用操作符

Flux<String> flux = Flux.just("a", "b", "c");

flux.map(s -> s.toUpperCase())     // 转换每个元素
    .filter(s -> !s.equals("B"))   // 过滤
    .doOnNext(s -> log.info(s))    // 副作用(不改变元素)
    .doOnComplete(() -> log.info("完成"))
    .doOnError(e -> log.error(e.getMessage()));

9.4 AtomicReference 线程安全

流式处理中,doOnNextdoOnComplete 可能在不同线程执行,需要线程安全的变量:

// 错误写法(非线程安全)
StringBuilder fullReply = new StringBuilder();

// 正确写法(线程安全引用)
AtomicReference<Long> selectedTemplateId = new AtomicReference<>(null);

// 在 doOnComplete 中安全读取
Long templateId = selectedTemplateId.get();

10. AOP 切面编程

10.1 AOP 核心概念

AOP(Aspect-Oriented Programming,面向切面编程)用于将横切关注点(日志、监控、事务等)从业务逻辑中分离。

核心术语

术语 说明 项目示例
Aspect(切面) 横切关注点的模块化 ApiMonitorAspect
JoinPoint(连接点) 程序执行的某个点 Controller 方法执行
Pointcut(切点) 匹配连接点的表达式 execution(* cn.edu.cdu.documind.controller..*(..))
Advice(通知) 切面在连接点执行的动作 @Around

10.2 ApiMonitorAspect 详解

@Slf4j
@Aspect        // 声明为切面
@Component     // 注册为 Spring Bean
@RequiredArgsConstructor
public class ApiMonitorAspect {

    // 切点表达式:拦截 controller 包下所有类的所有方法
    @Around("execution(* cn.edu.cdu.documind.controller..*(..))")
    public Object monitorApi(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        try {
            Object result = joinPoint.proceed();  // 执行目标方法
            return result;
        } catch (Exception e) {
            throw e;
        } finally {
            long responseTime = System.currentTimeMillis() - startTime;
            // 异步保存日志(不影响主流程性能)
            saveApiLog(endpoint, method, responseStatus, responseTime, ...);
        }
    }
}

10.3 切点表达式语法

execution(修饰符 返回类型 包名.类名.方法名(参数))

execution(* cn.edu.cdu.documind.controller..*(..))
         ↑  ↑                              ↑  ↑
         |  |                              |  └── 任意参数
         |  |                              └── 任意方法名
         |  └── 包名(.. 表示包含子包)
         └── 任意返回类型

10.4 异步日志的线程安全问题

问题@Async 方法在新线程中执行,此时 HttpServletRequestSecurityContext 已失效。

解决方案:在主线程中提前提取所有数据,再传给异步方法:

// ✅ 正确:在主线程(finally 块)中提取数据
String requestParams = getRequestParams(request);  // 主线程提取
String ipAddress = getClientIp(request);           // 主线程提取
Long userId = getCurrentUserId();                  // 主线程提取

// 传递基本类型,不传 request 对象
saveApiLog(endpoint, method, responseStatus, responseTime,
           requestParams, ipAddress, userId);  // 异步方法

// ❌ 错误:在异步方法中访问 request(已失效)
@Async
public void saveApiLog(HttpServletRequest request) {
    request.getParameter("xxx");  // 报错!request 已关闭
}

10.5 编程式事务(TransactionTemplate)

异步方法中不能使用 @Transactional(因为事务绑定到线程),需要用编程式事务:

@Autowired
private TransactionTemplate transactionTemplate;

@Async
public void saveApiLog(...) {
    transactionTemplate.execute(status -> {
        try {
            apiCallLogMapper.insert(log);
            return null;
        } catch (Exception e) {
            // 日志保存失败不影响主流程,不回滚
            return null;
        }
    });
}

11. 异步任务与事务管理

11.1 @Async 异步执行

@Configuration
@EnableAsync  // 开启异步支持
public class AsyncConfig {
    @Bean
    public TransactionTemplate transactionTemplate(
            PlatformTransactionManager transactionManager) {
        return new TransactionTemplate(transactionManager);
    }
}
@Async  // 标记为异步方法,在新线程中执行
@Transactional
public void vectorizeDocument(Long documentId) {
    // 这个方法在独立线程中执行,不阻塞调用方
}

注意事项

  • @Async 方法必须是 public
  • 不能在同一个类中调用自己的 @Async 方法(会绕过代理)
  • 默认使用 Spring 的 SimpleAsyncTaskExecutor,生产环境建议配置线程池

11.2 事务传播机制

@Transactional(rollbackFor = Exception.class)
public DocumentResponse uploadDocument(...) {
    // 保存文档记录到数据库
    documentMapper.insert(document);

    // 注册事务提交后的回调
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                // 事务提交后才启动向量化
                // 确保数据库中已有文档记录
                vectorService.vectorizeDocument(document.getId());
            }
        }
    );
}

为什么要在事务提交后启动向量化

  • 向量化是异步的,可能在事务提交前就开始执行
  • 如果向量化先执行,查询 documentId 时数据库中还没有这条记录
  • 使用 afterCommit 回调确保数据已持久化

11.3 @Transactional 常用属性

@Transactional(
    rollbackFor = Exception.class,  // 遇到任何异常都回滚(默认只回滚 RuntimeException)
    readOnly = true,                // 只读事务,优化查询性能
    propagation = Propagation.REQUIRED,  // 默认:有事务就加入,没有就新建
    timeout = 30                    // 超时时间(秒)
)

传播行为

传播行为 说明
REQUIRED 默认,有事务加入,没有新建
REQUIRES_NEW 总是新建事务,挂起当前事务
SUPPORTS 有事务加入,没有就不用事务
NOT_SUPPORTED 不使用事务,挂起当前事务

12. 全局异常处理

12.1 @RestControllerAdvice

@Slf4j
@RestControllerAdvice  // = @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {

    // 处理参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)  // 返回 400 状态码
    public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
        String errorMessage = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        return Result.error(400, errorMessage);
    }

    // 处理业务异常
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> handleIllegalArgumentException(IllegalArgumentException e) {
        return Result.error(400, e.getMessage());
    }

    // 处理权限异常
    @ExceptionHandler(SecurityException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public Result<Void> handleSecurityException(SecurityException e) {
        return Result.error(403, e.getMessage());
    }

    // 兜底处理
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常: ", e);
        return Result.error(500, "系统错误,请稍后重试");
    }
}

12.2 异常处理优先级

Spring 会选择最精确匹配@ExceptionHandler

MethodArgumentNotValidException(最精确)
    ↓ 不匹配
IllegalArgumentException
    ↓ 不匹配
RuntimeException
    ↓ 不匹配
Exception(最宽泛,兜底)

12.3 参数校验注解

// 在 DTO 上使用校验注解
public class LoginRequest {
    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 20, message = "密码长度 6-20 位")
    private String password;

    @Email(message = "邮箱格式不正确")
    private String email;
}

// 在 Controller 中触发校验
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
    // @Valid 触发校验,失败时抛出 MethodArgumentNotValidException
}

常用校验注解

注解 说明
@NotNull 不为 null
@NotBlank 不为空字符串(去空格后)
@NotEmpty 不为空集合/字符串
@Size(min,max) 长度范围
@Min / @Max 数值范围
@Email 邮箱格式
@Pattern(regexp) 正则表达式

13. 数据库设计

13.1 数据库表关系

user(用户)
  └── agent(智能体,多对一 user)
        ├── document(文档,多对一 agent)
        ├── chat_session(会话,多对一 agent)
        │     └── chat_history(对话历史,多对一 session)
        └── prompt_template(提示词模板,多对一 agent)

13.2 核心表设计详解

user 表
CREATE TABLE `user` (
  `id`         BIGINT NOT NULL AUTO_INCREMENT,
  `username`   VARCHAR(50) NOT NULL,
  `password`   VARCHAR(255) NOT NULL,  -- BCrypt 加密,固定 60 字符
  `email`      VARCHAR(100),
  `nickname`   VARCHAR(50),
  `role`       VARCHAR(20) DEFAULT 'user',  -- admin/user
  `status`     TINYINT DEFAULT 1,           -- 0-禁用 1-启用
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY `uk_username` (`username`)
);

设计要点

  • password 用 BCrypt 加密,长度固定 60 字符,VARCHAR(255) 留余量
  • ON UPDATE CURRENT_TIMESTAMP 自动更新修改时间
  • username 唯一索引防止重复注册
agent 表(核心表)
-- AI 模型配置
`model_name`  VARCHAR(50) DEFAULT 'qwen-plus',
`temperature` DECIMAL(3,2) DEFAULT 0.70,  -- 精度:3位数字,2位小数
`top_p`       DECIMAL(3,2) DEFAULT 0.90,

-- RAG 配置
`rag_enabled`             TINYINT DEFAULT 1,
`rag_top_k`               INT DEFAULT 10,
`rag_similarity_threshold` DECIMAL(3,2) DEFAULT 0.50,

-- 统计字段(冗余设计,避免频繁 COUNT 查询)
`document_count` INT DEFAULT 0,
`chat_count`     INT DEFAULT 0,
`total_tokens`   BIGINT DEFAULT 0,

冗余统计字段的权衡

  • 优点:查询智能体详情时无需 JOIN 或子查询,性能好
  • 缺点:需要在业务代码中维护一致性(增删时同步更新)
chat_history 表
CREATE TABLE `chat_history` (
  `session_id` VARCHAR(100) NOT NULL,  -- 会话 UUID
  `role`       VARCHAR(20) NOT NULL,   -- user/assistant
  `content`    TEXT NOT NULL,          -- 消息内容
  `sources`    JSON,                   -- 来源文档(JSON 数组)
  `tokens`     INT,                    -- token 消耗
  KEY `idx_session_id` (`session_id`)  -- 按会话查询的索引
);

JSON 类型字段:MySQL 5.7+ 支持 JSON 类型,可以存储结构化数据,支持 JSON 函数查询。

13.3 索引设计原则

-- 高频查询字段建索引
KEY `idx_user_id` (`user_id`)          -- 按用户查智能体
KEY `idx_session_id` (`session_id`)    -- 按会话查历史
KEY `idx_agent_user` (`agent_id`, `user_id`)  -- 联合索引

-- 唯一约束
UNIQUE KEY `uk_username` (`username`)
UNIQUE KEY `uk_session_id` (`session_id`)

索引原则

  • WHERE 条件字段、JOIN 字段、ORDER BY 字段考虑建索引
  • 联合索引遵循最左前缀原则
  • 不要过度建索引(影响写入性能)

14. 业务模块详解

14.1 用户认证模块

注册流程

POST /api/auth/register
    ↓
AuthService.register()
    ↓
检查用户名/邮箱唯一性(LambdaQueryWrapper)
    ↓
BCrypt 加密密码(passwordEncoder.encode)
    ↓
User.builder() 构建实体
    ↓
userMapper.insert(user)
    ↓
返回 UserResponse(不含密码)

登录流程

POST /api/auth/login
    ↓
AuthService.login()
    ↓
查询用户(selectOne by username)
    ↓
检查状态(status == 0 则禁用)
    ↓
验证密码(passwordEncoder.matches)
    ↓
生成 JWT(jwtUtil.generateToken(userId, username))
    ↓
返回 LoginResponse { accessToken, tokenType, expiresIn, user }

14.2 智能体管理模块

创建智能体

// AgentService.createAgent()
Agent agent = new Agent();
agent.setUserId(userId);           // 绑定用户
agent.setSystemPrompt(request.getSystemPrompt());  // 系统提示词
agent.setModelName(request.getModelName());        // AI 模型
agent.setRagEnabled(request.getRagEnabled());      // RAG 开关
// ... 其他配置
agentMapper.insert(agent);

分页查询

// 支持:关键词搜索 + 状态筛选 + 多字段排序 + 分页
LambdaQueryWrapper<Agent> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Agent::getUserId, userId);

if (keyword != null) {
    wrapper.and(w -> w.like(Agent::getName, keyword)
                      .or()
                      .like(Agent::getRoleName, keyword));
}
if (status != null) {
    wrapper.eq(Agent::getStatus, status);
}

// Java 17 switch 表达式
switch (sortBy) {
    case "name"      -> wrapper.orderBy(true, isAsc, Agent::getName);
    case "chatCount" -> wrapper.orderBy(true, isAsc, Agent::getChatCount);
    default          -> wrapper.orderBy(true, isAsc, Agent::getCreatedAt);
}

14.3 文档管理模块

上传文档完整流程

1. 验证文件(非空、大小、类型)
2. 验证智能体权限(agent.userId == 当前用户)
3. 生成唯一文件名(UUID + 原始文件名)
4. 创建目录(按 agentId 分目录存储)
5. 保存文件到磁盘(file.transferTo)
6. 插入 document 记录(vectorStatus=0)
7. 更新 agent.documentCount + 1
8. 注册事务提交后回调,启动异步向量化

文件目录结构

./data/uploads/
    ├── 5/          ← agentId=5 的文档
    │   ├── uuid1_健康档案.txt
    │   └── uuid2_体检报告.txt
    └── 6/          ← agentId=6 的文档
        └── uuid3_食谱.md

14.4 多提示词模板模块

PromptSelectorService 选择逻辑

// 根据用户消息内容,从模板列表中选择最匹配的模板
public PromptTemplate selectBestTemplate(Long agentId, String userMessage) {
    // 1. 查询该智能体所有启用的模板(按优先级降序)
    List<PromptTemplate> templates = promptTemplateMapper.selectList(
        new LambdaQueryWrapper<PromptTemplate>()
            .eq(PromptTemplate::getAgentId, agentId)
            .eq(PromptTemplate::getIsActive, 1)
            .orderByDesc(PromptTemplate::getPriority)
    );

    // 2. 遍历模板,检查触发条件
    for (PromptTemplate template : templates) {
        if (matchesTriggerCondition(template, userMessage)) {
            return template;  // 返回第一个匹配的(优先级最高)
        }
    }
    return null;  // 无匹配,使用默认系统提示词
}

触发条件 JSON 格式

{
  "keywords": ["急性", "剧烈", "疼痛"],
  "has_code_block": true,
  "message_length": {"min": 100, "max": 1000}
}

15. API 接口设计规范

15.1 RESTful 设计原则

项目遵循 RESTful 风格:

HTTP 方法 语义 示例
GET 查询 GET /api/agents 获取列表
POST 创建 POST /api/agents 创建智能体
PUT 全量更新 PUT /api/agents/{id} 更新智能体
PATCH 部分更新 PATCH /api/agents/{id}/status 切换状态
DELETE 删除 DELETE /api/agents/{id} 删除智能体

15.2 统一响应格式

// Result<T> 泛型响应类
{
    "code": 200,        // 业务状态码
    "message": "success",
    "data": { ... }     // 泛型数据
}

// 错误响应
{
    "code": 400,
    "message": "用户名不能为空",
    "data": null
}
// 使用方式
return Result.success(agentResponse);           // 200 + 数据
return Result.success("创建成功", agentResponse); // 200 + 自定义消息 + 数据
return Result.error(400, "参数错误");             // 400 + 错误消息
return Result.error(403, "无权访问");             // 403 + 错误消息

15.3 Swagger 文档注解

@Tag(name = "智能体管理", description = "智能体的增删改查接口")
@RestController
public class AgentController {

    @Operation(summary = "创建智能体", description = "创建一个新的智能体")
    @PostMapping
    public Result<AgentResponse> createAgent(...) { }

    @Operation(summary = "分页查询智能体")
    @GetMapping
    public Result<PageResponse<AgentResponse>> getAgentPage(
        @Parameter(description = "页码(从1开始)")
        @RequestParam(defaultValue = "1") Integer page,
        ...
    ) { }
}

访问地址:http://localhost:8080/swagger-ui.html

15.4 分页响应封装

// PageResponse<T> 通用分页响应
@Data
@Builder
public class PageResponse<T> {
    private List<T> items;    // 当前页数据
    private Long total;       // 总记录数
    private Integer page;     // 当前页码
    private Integer pageSize; // 每页数量
    private Integer totalPages; // 总页数
}

15.5 SecurityUtil 获取当前用户

// 在 Controller 中获取当前登录用户 ID
@GetMapping("/list")
public Result<List<AgentResponse>> getAgentList(Authentication authentication) {
    Long userId = SecurityUtil.getUserId(authentication);
    // ...
}

// SecurityUtil 实现
public class SecurityUtil {
    public static Long getUserId(Authentication authentication) {
        if (authentication == null) return null;
        CustomUserDetails userDetails =
            (CustomUserDetails) authentication.getPrincipal();
        return userDetails.getUserId();
    }
}

16. 配置管理与多环境

16.1 多环境配置

# application.yml(主配置,通用配置)
spring:
  profiles:
    active: dev  # 激活 dev 环境

# application-dev.yml(开发环境)
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/documind_ai
    username: health
    password: health

# application-prod.yml(生产环境,如果有)
spring:
  datasource:
    url: jdbc:mysql://prod-server:3306/documind_ai

激活方式

# 方式1:配置文件
spring.profiles.active=dev

# 方式2:启动参数
java -jar app.jar --spring.profiles.active=prod

# 方式3:环境变量
SPRING_PROFILES_ACTIVE=prod

16.2 敏感配置用环境变量

# 不要把 API Key 写死在配置文件中
spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}  # 从环境变量读取

设置环境变量

# Linux/Mac
export DASHSCOPE_API_KEY=sk-xxxxx

# Windows
set DASHSCOPE_API_KEY=sk-xxxxx

# IDEA 运行配置:Edit Configurations → Environment variables

16.3 HikariCP 连接池配置

spring:
  datasource:
    hikari:
      maximum-pool-size: 10   # 最大连接数
      minimum-idle: 5          # 最小空闲连接
      connection-timeout: 30000  # 获取连接超时(毫秒)

HikariCP 是 Spring Boot 默认的连接池,以高性能著称。

16.4 MyBatis-Plus 全局配置

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true  # 下划线转驼峰(user_id → userId)
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 打印 SQL
  global-config:
    db-config:
      id-type: auto  # 全局主键策略:自增

下划线转驼峰:数据库字段 user_id 自动映射到 Java 字段 userId,无需手动配置 @TableField(但项目中仍然写了,更清晰)。

16.5 文件上传配置

spring:
  servlet:
    multipart:
      max-file-size: 50MB      # 单文件最大 50MB
      max-request-size: 50MB   # 请求体最大 50MB
      enabled: true

17. Lombok 使用指南

17.1 项目中使用的 Lombok 注解

注解 作用 使用场景
@Data getter + setter + toString + equals + hashCode 实体类、DTO
@Builder 建造者模式 创建复杂对象
@NoArgsConstructor 无参构造器 实体类(MyBatis 需要)
@AllArgsConstructor 全参构造器 实体类
@RequiredArgsConstructor final 字段构造器 Service(依赖注入)
@Slf4j 注入 log 日志对象 需要打日志的类

17.2 @Builder 使用示例

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private Long id;
    private String username;
    private String email;
}

// 使用 Builder 创建对象(链式调用,清晰易读)
User user = User.builder()
    .username("admin")
    .email("admin@example.com")
    .role("admin")
    .status(1)
    .createdAt(LocalDateTime.now())
    .build();

注意@Builder@NoArgsConstructor + @AllArgsConstructor 同时使用时,需要三个注解都写,否则 MyBatis 反射创建对象会失败。

17.3 @RequiredArgsConstructor 原理

// 写法
@RequiredArgsConstructor
public class ChatService {
    private final AgentMapper agentMapper;
    private final ChatHistoryService historyService;
}

// Lombok 生成的代码
public class ChatService {
    private final AgentMapper agentMapper;
    private final ChatHistoryService historyService;

    // 自动生成包含所有 final 字段的构造器
    public ChatService(AgentMapper agentMapper,
                       ChatHistoryService historyService) {
        this.agentMapper = agentMapper;
        this.historyService = historyService;
    }
}

Spring 看到只有一个构造器时,自动用它进行依赖注入(无需 @Autowired)。

17.4 @Slf4j 日志使用

@Slf4j
@Service
public class AgentService {

    public AgentResponse createAgent(Long userId, AgentCreateRequest request) {
        log.info("创建智能体 - 用户ID: {}, 名称: {}", userId, request.getName());
        // {} 是占位符,避免字符串拼接的性能损耗

        log.debug("调试信息: {}", someObject);  // DEBUG 级别
        log.warn("警告: {}", warningMsg);        // WARN 级别
        log.error("错误: ", exception);          // ERROR 级别(异常用逗号)
    }
}

日志级别(从低到高):TRACE < DEBUG < INFO < WARN < ERROR

配置文件中设置级别:

logging:
  level:
    root: INFO                      # 全局 INFO
    cn.edu.cdu.documind: DEBUG      # 项目包 DEBUG

18. Redis 缓存集成

18.1 Redis 配置

spring:
  data:
    redis:
      host: localhost
      port: 6379
      password:          # 无密码留空
      database: 1        # 使用 DB1(与其他应用隔离)
      timeout: 3000ms
      lettuce:           # Lettuce 是 Spring Boot 默认的 Redis 客户端
        pool:
          max-active: 8  # 最大连接数
          max-idle: 8    # 最大空闲连接
          min-idle: 0
          max-wait: -1ms # 等待连接超时(-1 表示无限等待)

18.2 Redis 在项目中的作用

项目引入了 Redis 依赖,主要用于:

  • 会话缓存:缓存用户会话信息,减少数据库查询
  • Token 黑名单:存储已注销的 JWT Token(防止 Token 被滥用)
  • 统计数据缓存:缓存智能体统计数据

18.3 RedisTemplate 基本使用

@Autowired
private RedisTemplate<String, Object> redisTemplate;

// 字符串操作
redisTemplate.opsForValue().set("key", "value");
redisTemplate.opsForValue().set("key", "value", 1, TimeUnit.HOURS); // 1小时过期
String value = (String) redisTemplate.opsForValue().get("key");

// Hash 操作(适合存储对象)
redisTemplate.opsForHash().put("user:1", "username", "admin");
redisTemplate.opsForHash().get("user:1", "username");

// 删除
redisTemplate.delete("key");

// 判断是否存在
Boolean exists = redisTemplate.hasKey("key");

// 设置过期时间
redisTemplate.expire("key", 30, TimeUnit.MINUTES);

18.4 StringRedisTemplate

// 专门处理字符串类型,更常用
@Autowired
private StringRedisTemplate stringRedisTemplate;

stringRedisTemplate.opsForValue().set("token:blacklist:" + token, "1",
    24, TimeUnit.HOURS);  // JWT 黑名单,24小时后自动清除

19. 文件上传与处理

19.1 MultipartFile 文件上传

// Controller 接收文件
@PostMapping("/upload")
public Result<DocumentResponse> uploadDocument(
        @RequestParam("agentId") Long agentId,
        @RequestParam("file") MultipartFile file,  // 接收上传文件
        Authentication authentication) {
    // ...
}

// Service 处理文件
public DocumentResponse uploadDocument(Long agentId, Long userId,
        MultipartFile file) throws IOException {

    // 获取文件信息
    String originalFilename = file.getOriginalFilename();  // 原始文件名
    long fileSize = file.getSize();                        // 文件大小(字节)
    String contentType = file.getContentType();            // MIME 类型

    // 生成唯一文件名(防止重名覆盖)
    String uniqueFileName = UUID.randomUUID() + "_" + originalFilename;

    // 保存文件
    Path filePath = agentUploadPath.resolve(uniqueFileName);
    file.transferTo(filePath.toFile());  // 写入磁盘
}

19.2 文件路径处理

// 处理相对路径 vs 绝对路径
File baseDir = new File(uploadBasePath);  // "./data/uploads"
if (!baseDir.isAbsolute()) {
    // 转换为绝对路径(相对于 JVM 工作目录)
    baseDir = new File(System.getProperty("user.dir"), uploadBasePath);
}

// 按 agentId 分目录
Path agentUploadPath = Paths.get(baseDir.getAbsolutePath(), agentId.toString());

// 创建目录(包括父目录)
agentUploadPath.toFile().mkdirs();

19.3 文档解析

PDF 解析(PDFBox)

// Spring AI 的 TextReader 内部使用 PDFBox
DocumentReader reader = new TextReader(new FileSystemResource(file));
List<Document> documents = reader.get();

Excel 解析(Apache POI)

// 自定义 ExcelReader
public class ExcelReader implements DocumentReader {
    @Override
    public List<Document> get() {
        try (Workbook workbook = WorkbookFactory.create(resource.getInputStream())) {
            StringBuilder content = new StringBuilder();
            for (Sheet sheet : workbook) {
                for (Row row : sheet) {
                    for (Cell cell : row) {
                        content.append(cell.toString()).append("\t");
                    }
                    content.append("\n");
                }
            }
            return List.of(new Document(content.toString()));
        }
    }
}

19.4 支持的文件类型

private boolean isValidFileType(String fileType) {
    List<String> validTypes = List.of("pdf", "doc", "docx", "txt", "md", "xls", "xlsx");
    return validTypes.contains(fileType.toLowerCase());
}
文件类型 解析方式
txt, md TextReader(直接读取)
pdf TextReader(PDFBox 提取文本)
doc, docx TextReader(POI 提取文本)
xls, xlsx ExcelReader(自定义,POI 解析)

20. 项目架构与设计模式

20.1 三层架构

Controller(表现层)
    ↓ 调用
Service(业务逻辑层)
    ↓ 调用
Mapper(数据访问层)
    ↓ 操作
Database(数据库)

各层职责

  • Controller:接收请求、参数校验、调用 Service、返回响应
  • Service:业务逻辑、事务管理、调用多个 Mapper
  • Mapper:数据库 CRUD,不含业务逻辑

20.2 DTO 模式

项目严格区分 Entity 和 DTO:

Entity(数据库实体)← Mapper → 数据库
    ↓ 转换
DTO(数据传输对象)← Controller → 前端
// Entity:对应数据库表,包含所有字段(含密码等敏感字段)
public class User {
    private Long id;
    private String username;
    private String password;  // 敏感字段
    // ...
}

// Response DTO:返回给前端,不含敏感字段
public class UserResponse {
    private Long id;
    private String username;
    // 没有 password 字段!
}

// 转换
private UserResponse convertToUserResponse(User user) {
    return UserResponse.builder()
        .id(user.getId())
        .username(user.getUsername())
        // 不映射 password
        .build();
}

20.3 Builder 模式

项目大量使用 Builder 模式创建对象:

// 链式调用,清晰表达每个字段的含义
LoginResponse response = LoginResponse.builder()
    .accessToken(token)
    .tokenType("Bearer")
    .expiresIn(jwtExpiration)
    .user(convertToUserResponse(user))
    .build();

20.4 策略模式(多提示词选择)

PromptSelectorService 实现了策略模式:根据用户消息动态选择不同的提示词策略。

// 不同场景使用不同提示词(策略)
if (matchKeywords(message, ["急性", "剧烈"])) → 紧急风险评估提示词
if (matchKeywords(message, ["报告", "体检"])) → 体检报告分析提示词
else                                          → 日常健康咨询提示词

20.5 观察者模式(事务同步)

TransactionSynchronization 是观察者模式的应用:

// 注册观察者:事务提交后触发
TransactionSynchronizationManager.registerSynchronization(
    new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // 事务提交事件发生后,执行向量化
            vectorService.vectorizeDocument(document.getId());
        }
    }
);

20.6 Java 17 新特性应用

switch 表达式(Java 14+):

switch (sortBy) {
    case "name"      -> wrapper.orderBy(true, isAsc, Agent::getName);
    case "updatedAt" -> wrapper.orderBy(true, isAsc, Agent::getUpdatedAt);
    default          -> wrapper.orderByDesc(Agent::getCreatedAt);
}

var 局部变量类型推断(Java 10+):

var agent = agentMapper.selectById(agentId);  // 类型由右侧推断
var testResults = vectorStore.similaritySearch(...);

instanceof 模式匹配(Java 16+):

// 旧写法
if (vectorStore instanceof SimpleVectorStore) {
    SimpleVectorStore simpleVectorStore = (SimpleVectorStore) vectorStore;
    simpleVectorStore.save(vectorFile);
}

// 新写法(项目中使用)
if (vectorStore instanceof SimpleVectorStore simpleVectorStore) {
    simpleVectorStore.save(vectorFile);  // 直接使用,无需强转
}

21. 对话历史与会话管理

21.1 会话(Session)设计

用户
 └── 智能体 A
       ├── 会话1(session_id: uuid-001)
       │     ├── 消息1: user: "你好"
       │     ├── 消息2: assistant: "你好!"
       │     └── 消息3: user: "帮我分析体检报告"
       └── 会话2(session_id: uuid-002)
             └── 消息1: user: "最近头疼"

会话 ID 生成:使用 UUID,保证全局唯一:

String sessionId = UUID.randomUUID().toString();

21.2 多轮对话上下文管理

// 获取最近 N 条历史(避免 Prompt 过长)
List<ChatHistory> histories = historyService.getHistoryBySession(sessionId, 10);

// 构建消息列表
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(agent.getSystemPrompt()));  // 系统提示词

// 添加历史对话(保持上下文)
for (ChatHistory history : histories) {
    if ("user".equals(history.getRole())) {
        messages.add(new UserMessage(history.getContent()));
    } else if ("assistant".equals(history.getRole())) {
        messages.add(new AssistantMessage(history.getContent()));
    }
}

messages.add(new UserMessage(userMessage));  // 当前问题

历史条数限制

  • 同步接口:最近 10 条
  • 流式接口:最近 5 条(流式更注重实时性,减少延迟)

21.3 重新生成回答

public Flux<String> regenerateResponse(Long agentId, String sessionId, Long userId) {
    List<ChatHistory> histories = historyService.getHistoryBySession(sessionId, 100);

    // 从后往前找最后一条用户消息和 AI 回复
    String lastUserMessage = null;
    Long lastAssistantMessageId = null;

    for (int i = histories.size() - 1; i >= 0; i--) {
        ChatHistory history = histories.get(i);
        if ("assistant".equals(history.getRole()) && lastAssistantMessageId == null) {
            lastAssistantMessageId = history.getId();  // 记录要删除的 AI 回复
        } else if ("user".equals(history.getRole())) {
            lastUserMessage = history.getContent();    // 找到最后一条用户消息
            break;
        }
    }

    // 删除最后一条 AI 回复
    if (lastAssistantMessageId != null) {
        historyService.deleteMessage(lastAssistantMessageId);
    }

    // 用同样的用户消息重新生成
    return chatStream(agentId, sessionId, lastUserMessage, userId);
}

22. 安全设计最佳实践

22.1 密码安全

// 注册时加密
user.setPassword(passwordEncoder.encode(request.getPassword()));
// BCrypt 每次生成不同的 hash(含随机 salt),相同密码 hash 不同

// 登录时验证
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
    throw new RuntimeException("用户名或密码错误");
    // 注意:不要说"密码错误",要说"用户名或密码错误",防止用户名枚举攻击
}

BCrypt 特点

  • 自动加盐(salt),防彩虹表攻击
  • 计算慢(可调节),防暴力破解
  • 相同密码每次 hash 不同,但 matches 能正确验证

22.2 数据权限控制

每个接口都验证数据归属,防止越权访问:

// 验证智能体属于当前用户
Agent agent = agentMapper.selectById(agentId);
if (!agent.getUserId().equals(userId)) {
    throw new SecurityException("无权操作该智能体");
}

// 验证文档属于当前用户(通过智能体间接验证)
Document document = documentMapper.selectById(id);
Agent agent = agentMapper.selectById(document.getAgentId());
if (!agent.getUserId().equals(userId)) {
    throw new SecurityException("无权访问该文档");
}

22.3 JWT 安全配置

jwt:
  # 密钥至少 256 位(32 字节)才能用于 HS256
  secret: documind-ai-secret-key-for-jwt-token-generation-must-be-at-least-256-bits-long
  expiration: 86400000  # 24小时,不要设置太长

生产环境建议

  • 密钥从环境变量读取,不写在配置文件中
  • 考虑实现 Token 刷新机制(Refresh Token)
  • 实现 Token 黑名单(注销时加入 Redis 黑名单)

22.4 SQL 注入防护

MyBatis-Plus 的 LambdaQueryWrapper 使用预编译语句,自动防止 SQL 注入:

// 安全:使用参数化查询
wrapper.eq(Agent::getUserId, userId)
// 生成:WHERE user_id = ?(参数化)

// 危险:字符串拼接(不要这样做)
"WHERE user_id = " + userId  // 可能被注入

23. 统计与监控模块

23.1 API 调用日志

ApiCallLog 实体记录每次 API 调用:

@TableName("api_call_log")
public class ApiCallLog {
    private Long id;
    private Long userId;          // 调用用户
    private String endpoint;      // 接口路径
    private String httpMethod;    // HTTP 方法
    private String requestParams; // 请求参数(JSON)
    private Integer responseStatus; // 响应状态码
    private Integer responseTime;   // 响应时间(毫秒)
    private String errorMessage;    // 错误信息
    private String ipAddress;       // 客户端 IP
    private String userAgent;       // 浏览器信息
    private LocalDateTime createdAt;
}

23.2 IP 地址获取

private String getClientIp(HttpServletRequest request) {
    // 优先从代理头获取真实 IP
    String ip = request.getHeader("X-Forwarded-For");
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getHeader("X-Real-IP");  // Nginx 代理
    }
    if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
        ip = request.getRemoteAddr();  // 直连 IP
    }
    // 多级代理时取第一个(真实客户端 IP)
    if (ip != null && ip.contains(",")) {
        ip = ip.split(",")[0].trim();
    }
    return ip;
}

23.3 统计数据查询

// StatisticsService 提供统计概览
public StatisticsOverviewResponse getOverview(Long userId) {
    // 智能体总数
    Long agentCount = agentMapper.selectCount(
        new LambdaQueryWrapper<Agent>().eq(Agent::getUserId, userId));

    // 文档总数(通过 agent_id 关联)
    Long documentCount = documentMapper.selectCount(...);

    // 总对话次数(汇总所有智能体)
    Long totalChats = agentMapper.sumChatCount(userId);

    // 总 token 消耗
    Long totalTokens = agentMapper.sumTotalTokens(userId);

    return StatisticsOverviewResponse.builder()
        .agentCount(agentCount)
        .documentCount(documentCount)
        .totalChats(totalChats)
        .totalTokens(totalTokens)
        .build();
}

24. 常见问题与解决方案

24.1 向量化后检索不到数据

问题:上传文档后,RAG 检索不到相关内容。

排查步骤

// 1. 检查向量化状态(vectorStatus 是否为 2)
Document doc = documentMapper.selectById(documentId);
System.out.println("向量化状态: " + doc.getVectorStatus());

// 2. 检查向量文件是否存在
File vectorFile = new File("./data/vector-store.json");
System.out.println("向量文件大小: " + vectorFile.length());

// 3. 验证向量加载(VectorStoreConfig 中的诊断代码)
var testResults = vectorStore.similaritySearch(
    SearchRequest.builder().query("测试").topK(20)
        .similarityThreshold(0.0).build()
);
System.out.println("内存中向量数: " + testResults.size());

// 4. 检查 agent_id 是否正确写入 metadata
testResults.forEach(doc ->
    System.out.println("agent_id: " + doc.getMetadata().get("agent_id")));

常见原因

  • 向量文件路径不一致(相对路径解析问题)
  • 应用重启后向量文件未加载
  • agent_id 类型不匹配(Long vs String)

24.2 流式响应中断

问题:SSE 流式输出中途断开。

解决方案

// 前端设置重连
const eventSource = new EventSource('/api/chat/stream');
eventSource.onerror = () => {
    // 自动重连
    setTimeout(() => reconnect(), 3000);
};

// 后端设置超时
server:
  tomcat:
    connection-timeout: 300000  # 5分钟

24.3 异步事务问题

问题@Async 方法中 @Transactional 不生效。

原因@Transactional 基于 AOP 代理,而 @Async 在新线程中执行,事务上下文不传递。

解决方案:使用 TransactionTemplate 编程式事务(项目中已采用此方案)。

24.4 CORS 跨域问题

问题:前端请求报 CORS 错误。

检查点

// 确保 SecurityConfig 中 CORS 配置正确
configuration.setAllowedOriginPatterns(List.of("*"));
// 注意:不能用 setAllowedOrigins("*") + setAllowCredentials(true),会报错

24.5 MyBatis-Plus 分页不生效

问题:分页查询返回所有数据。

原因:未配置分页插件。

解决方案:确保 MybatisPlusConfig 中添加了 PaginationInnerInterceptor,且 DbType 与数据库匹配。


25. 项目启动与部署

25.1 本地开发环境准备

前置条件

✅ JDK 17+
✅ Maven 3.8+
✅ MySQL 8.0+
✅ Redis 6.0+
✅ 阿里云百炼平台 API Key(DASHSCOPE_API_KEY)

数据库初始化

mysql -u root -p < database/init-v2.sql

设置环境变量

# Windows(IDEA 中配置)
DASHSCOPE_API_KEY=sk-your-api-key

# Linux/Mac
export DASHSCOPE_API_KEY=sk-your-api-key

修改数据库配置

# application-dev.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/documind_ai
    username: your_username
    password: your_password
  data:
    redis:
      host: localhost
      port: 6379

25.2 启动命令

# 方式1:Maven 直接运行
mvn spring-boot:run

# 方式2:打包后运行
mvn clean package -DskipTests
java -jar target/documind-0.0.1-SNAPSHOT.jar

# 方式3:指定环境
java -jar target/documind-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

25.3 验证启动成功

# 访问 Swagger UI
http://localhost:8080/swagger-ui.html

# 测试登录接口
curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"test","password":"test123"}'

25.4 数据目录结构

项目根目录/
├── data/
│   ├── uploads/          ← 上传的文档文件
│   │   ├── {agentId}/
│   │   │   └── {uuid}_{filename}
│   └── vector-store.json ← 向量数据持久化文件

26. 知识点速查表

26.1 Spring Boot 注解速查

注解 位置 作用
@SpringBootApplication 主类 启动入口,包含 @Configuration + @EnableAutoConfiguration + @ComponentScan
@RestController REST 控制器,方法返回 JSON
@RequestMapping 类/方法 映射请求路径
@GetMapping 方法 GET 请求
@PostMapping 方法 POST 请求
@PutMapping 方法 PUT 请求
@DeleteMapping 方法 DELETE 请求
@PatchMapping 方法 PATCH 请求
@PathVariable 参数 路径变量 /agents/{id}
@RequestParam 参数 查询参数 ?page=1
@RequestBody 参数 请求体(JSON)
@Valid 参数 触发参数校验
@Service 业务层 Bean
@Configuration 配置类
@Bean 方法 注册 Bean
@Value 字段 注入配置值
@Transactional 方法/类 事务管理
@Async 方法 异步执行

26.2 MyBatis-Plus 注解速查

注解 说明
@TableName("表名") 指定数据库表名
@TableId(type=IdType.AUTO) 主键,自增
@TableField("列名") 指定数据库列名
@TableLogic 逻辑删除字段

26.3 HTTP 状态码速查

状态码 含义 项目使用场景
200 成功 正常响应
400 请求错误 参数校验失败、业务参数错误
401 未认证 未登录或 Token 无效
403 无权限 越权访问
404 未找到 资源不存在
500 服务器错误 系统异常

26.4 项目接口速查

接口 方法 路径 说明
注册 POST /api/auth/register 用户注册
登录 POST /api/auth/login 用户登录
创建智能体 POST /api/agents 创建智能体
智能体列表 GET /api/agents/list 获取列表
智能体详情 GET /api/agents/{id} 获取详情
更新智能体 PUT /api/agents/{id} 更新
删除智能体 DELETE /api/agents/{id} 删除
上传文档 POST /api/documents/upload 上传文档
文档列表 GET /api/documents 文档列表
同步对话 POST /api/chat/send 同步问答
流式对话 POST /api/chat/stream SSE 流式问答
对话历史 GET /api/chat/history/{sessionId} 获取历史
创建会话 POST /api/sessions 新建会话

27. 扩展学习建议

27.1 当前技术的进阶方向

向量数据库升级

  • 当前:SimpleVectorStore(内存 + JSON 文件)
  • 进阶:Qdrant(支持过滤、持久化、分布式)
  • 学习:Qdrant Spring AI 集成文档

大模型能力扩展

  • 当前:文本对话
  • 进阶:多模态(图片理解)、Function Calling(工具调用)
  • 学习:Spring AI 官方文档 Function Calling 章节

认证体系增强

  • 当前:JWT 无状态认证
  • 进阶:OAuth2 第三方登录、Refresh Token 机制
  • 学习:Spring Security OAuth2 文档

27.2 性能优化方向

1. 数据库连接池调优(HikariCP 参数)
2. Redis 缓存热点数据(智能体配置、用户信息)
3. 向量检索优化(升级 Qdrant,使用原生过滤)
4. 异步线程池配置(自定义 ThreadPoolTaskExecutor)
5. 文档分块策略优化(语义分块 vs Token 分块)

27.3 推荐学习资源

技术 推荐资源
Spring Boot spring.io/guides
Spring AI docs.spring.io/spring-ai
Spring AI Alibaba java.aliyun.com/spring-ai-alibaba
MyBatis-Plus baomidou.com
Spring Security spring.io/projects/spring-security
Project Reactor projectreactor.io
RAG 原理 《Building LLM Apps》

27.4 代码质量提升

单元测试

@SpringBootTest
class AgentServiceTest {
    @Autowired
    private AgentService agentService;

    @Test
    void testCreateAgent() {
        AgentCreateRequest request = new AgentCreateRequest();
        request.setName("测试智能体");
        request.setSystemPrompt("你是测试助手");
        // ...
        AgentResponse response = agentService.createAgent(1L, request);
        assertNotNull(response.getId());
    }
}

代码规范

  • 方法长度不超过 50 行
  • 类长度不超过 500 行
  • 每个方法只做一件事(单一职责)
  • 异常信息要有意义,便于排查

28. 总结

28.1 项目技术亮点

  1. Spring AI Alibaba 集成:使用最新的 Spring AI 框架对接通义千问,支持同步和流式两种调用方式

  2. RAG 架构:文档向量化 + 相似度检索 + 大模型生成,实现基于私有文档的精准问答

  3. 多提示词策略:根据用户消息内容动态选择最合适的系统提示词,提升 AI 回复质量

  4. 无状态认证:JWT + Spring Security,无需 Session,天然支持水平扩展

  5. AOP 监控:切面自动记录所有 API 调用,无侵入式监控

  6. 异步向量化:文档上传后异步处理,不阻塞用户操作

28.2 学习路径建议

初级:Spring Boot 基础 → MyBatis-Plus CRUD → REST API 设计
  ↓
中级:Spring Security + JWT → 事务管理 → AOP 切面
  ↓
高级:Spring AI 集成 → RAG 架构 → 流式响应 → 向量数据库
  ↓
进阶:性能优化 → 分布式部署 → 监控告警

28.3 核心设计原则

  • 数据隔离:通过 agent_id 确保每个智能体的数据相互独立
  • 权限控制:每个接口都验证数据归属,防止越权
  • 异步解耦:耗时操作(向量化、日志)异步处理,不影响主流程
  • 统一响应Result<T> 统一封装,前端处理一致
  • 配置外化:敏感配置通过环境变量注入,不写死在代码中

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐