This commit is contained in:
yahaozhang
2025-07-20 16:55:23 +08:00
parent 36c5d7f760
commit 8962943123
18 changed files with 1231 additions and 23 deletions

View File

@@ -9,7 +9,10 @@
"Bash(cd:*)", "Bash(cd:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(find:*)", "Bash(find:*)",
"Bash(git add:*)" "Bash(git add:*)",
"Bash(git commit:*)",
"Bash(chmod:*)",
"Bash(docker build:*)"
], ],
"deny": [] "deny": []
} }

1
.idea/compiler.xml generated
View File

@@ -2,6 +2,7 @@
<project version="4"> <project version="4">
<component name="CompilerConfiguration"> <component name="CompilerConfiguration">
<annotationProcessing> <annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true"> <profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" /> <sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" /> <sourceTestOutputDir name="target/generated-test-sources/test-annotations" />

24
Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# Use official OpenJDK 17 runtime as base image
FROM openjdk:17-jdk-slim
# Set working directory
WORKDIR /app
# Copy Maven wrapper and pom.xml first for better layer caching
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
# Download dependencies (this layer will be cached unless pom.xml changes)
RUN ./mvnw dependency:go-offline -B
# Copy source code
COPY src ./src
# Build the application
RUN ./mvnw clean package -DskipTests
# Expose port 8080
EXPOSE 8080
# Run the jar file
CMD ["java", "-jar", "target/chat-0.0.1-SNAPSHOT.jar"]

69
deploy.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# Deploy script for backserver to 101.200.154.78
# Usage: ./deploy.sh
# Configuration
SERVER_HOST="101.200.154.78"
SERVER_USER="root"
DEPLOY_PATH="/opt/backserver"
CONTAINER_NAME="backserver-app"
echo "Starting deployment to $SERVER_HOST..."
# Build the Docker image locally
echo "Building Docker image..."
docker build -t backserver:latest .
if [ $? -ne 0 ]; then
echo "Docker build failed!"
exit 1
fi
# Save the image to a tar file
echo "Saving Docker image to tar file..."
docker save backserver:latest > backserver.tar
# Transfer files to server
echo "Transferring files to server..."
scp backserver.tar docker-compose.yml $SERVER_USER@$SERVER_HOST:$DEPLOY_PATH/
if [ $? -ne 0 ]; then
echo "File transfer failed!"
exit 1
fi
# Execute deployment commands on server
echo "Executing deployment on server..."
ssh $SERVER_USER@$SERVER_HOST << EOF
cd $DEPLOY_PATH
# Stop existing container
docker-compose down
# Load new image
docker load < backserver.tar
# Start new container
docker-compose up -d
# Clean up
rm backserver.tar
# Show status
docker-compose ps
docker logs $CONTAINER_NAME --tail 20
EOF
if [ $? -eq 0 ]; then
echo "Deployment completed successfully!"
echo "Application should be available at http://$SERVER_HOST:8080"
else
echo "Deployment failed!"
exit 1
fi
# Clean up local tar file
rm backserver.tar
echo "Deployment process finished."

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: '3.8'
services:
app:
build: .
container_name: backserver-app
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
- SPRING_DATASOURCE_URL=jdbc:mysql://101.200.154.78:3306/yunda_qa?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
- SPRING_DATASOURCE_USERNAME=root
- SPRING_DATASOURCE_PASSWORD=mysql_Jt3yzh
restart: unless-stopped
networks:
- app-network
networks:
app-network:
driver: bridge

129
docs/chat.md Normal file
View File

@@ -0,0 +1,129 @@
好的,以下是 简化版的智能问答接口设计,聚焦两个核心问答模式:
• ✅ 普通聊天chat
• ✅ 深度研究research
省略了 history_limit、model、parameters、metadata 等高级控制参数,仅保留核心功能字段。
✅ 请求接口设计
POST /api/qa/ask
📥 请求体JSON
{
"mode": "chat", // chat | research问答模式
"conversation_id": "12345", // 可选会话ID用于上下文关联
"message": "什么是RAG" // 用户输入的问题
}
📌 字段说明
字段名 类型 必填 说明
mode string 是 chat 普通聊天research 深度研究
conversation_id string 否 可选;为空则创建新会话
message string 是 用户发送的问题内容
✅ 响应数据设计
📤 响应体JSON
{
"success": true,
"conversation_id": "12345",
"message_id": "msg_001",
"answer": "RAG 是一种结合检索与生成的问答系统架构...",
"references": [], // chat 模式为空research 模式可能返回引用资料
"report_id": null, // 仅在 research 模式中,若已触发报告生成
"tokens_used": 198,
"time_ms": 732
}
📌 字段说明
字段名 类型 说明
success boolean 是否成功
conversation_id string 当前所属会话 ID
message_id string 当前返回消息的唯一标识
answer string 模型生成的回答内容
references array 若为 research 模式,可能包含引用资料;否则为空
report_id string | null 若生成报告返回报告ID否则为 null
tokens_used number 生成过程使用的 token 数量(可用于统计/计费)
time_ms number 模型生成耗时(毫秒)
✅ 示例
📌 普通聊天模式
请求
{
"mode": "chat",
"message": "你好,帮我解释一下 LLM 是什么?"
}
响应
{
"success": true,
"conversation_id": "abc123",
"message_id": "msg_1001",
"answer": "LLMLarge Language Model是指大型语言模型...",
"references": [],
"report_id": null,
"tokens_used": 154,
"time_ms": 645
}
📌 深度研究模式
请求
{
"mode": "research",
"conversation_id": "r20240718_001",
"message": "请详细说明 RAG 在金融领域的应用。"
}
响应
{
"success": true,
"conversation_id": "r20240718_001",
"message_id": "msg_2009",
"answer": "在金融领域RAG 可用于增强问答系统...",
"references": [
{
"title": "RAG 应用于金融智能问答",
"url": "https://example.com/rag-finance",
"quote": "金融领域中的RAG系统能显著提升精确率..."
}
],
"report_id": "report_84928",
"tokens_used": 341,
"time_ms": 1024
}
✅ 结构总结(精简版)
模式 关键差异
chat 只返回回答,无引用、无报告
research 返回引用资料、可返回 report_id
如需下一步生成 Swagger/OpenAPI 接口文档、接口校验 Schema、数据库如何存储这些内容欢迎继续提问。

13
pom.xml
View File

@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.4</version> <version>3.2.4</version>
<relativePath/> <relativePath/>
</parent> </parent>
@@ -108,6 +108,17 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version> <version>2.5.0</version>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -3,15 +3,18 @@ package com.yundage.chat.config;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.concurrent.DelegatingSecurityContextExecutorService;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
@@ -22,23 +25,25 @@ import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays; import java.util.Arrays;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
@Autowired @Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter; private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired @Autowired
private UserDetailsService userDetailsService; private UserDetailsService userDetailsService;
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
@Bean @Bean
public AuthenticationProvider authenticationProvider() { public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
@@ -46,12 +51,12 @@ public class SecurityConfig {
authProvider.setPasswordEncoder(passwordEncoder()); authProvider.setPasswordEncoder(passwordEncoder());
return authProvider; return authProvider;
} }
@Bean @Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager(); return config.getAuthenticationManager();
} }
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
@@ -63,35 +68,68 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http
// 1. CORS 和 CSRF 配置
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
// 2. 路由权限配置
.authorizeHttpRequests(authz -> authz .authorizeHttpRequests(authz -> authz
// 认证相关接口 // 允许所有 OPTIONS 请求CORS 预检)
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
// 登录 / 注册
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
// 公开API
.requestMatchers("/api/public/**").permitAll() // Swagger 文档
.requestMatchers(
"/swagger-ui/**",
"/swagger-ui.html",
"/api-docs/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**"
).permitAll()
// 健康检查 // 健康检查
.requestMatchers("/health").permitAll() .requestMatchers("/health").permitAll()
// Swagger API文档
.requestMatchers("/swagger-ui/**").permitAll() // 公开接口
.requestMatchers("/swagger-ui.html").permitAll() .requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api-docs/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll() // 聊天接口(允许流式访问)
.requestMatchers("/swagger-resources/**").permitAll() .requestMatchers(HttpMethod.POST, "/qa/ask/stream").permitAll()
.requestMatchers("/webjars/**").permitAll()
// 其他所有请求需要认证 // 聊天相关接口需认证
.requestMatchers("/qa/**").authenticated()
// 所有其他请求需认证
.anyRequest().authenticated() .anyRequest().authenticated()
) )
// 3. 无状态会话配置(适用于 JWT
.sessionManagement(session -> session .sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
) )
// 4. 认证提供者与 JWT 过滤器
.authenticationProvider(authenticationProvider()) .authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// 设置安全上下文策略(适用于多线程环境)
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
return http.build(); return http.build();
} }
@Bean
public ExecutorService securityContextExecutorService() {
// 创建一个固定大小的线程池并使用DelegatingSecurityContextExecutorService包装
// 这样线程池中的线程会自动继承安全上下文
ExecutorService executorService = Executors.newFixedThreadPool(10);
return new DelegatingSecurityContextExecutorService(executorService);
}
} }

View File

@@ -0,0 +1,90 @@
package com.yundage.chat.controller;
import com.yundage.chat.common.ApiResponse;
import com.yundage.chat.dto.ChatRequest;
import com.yundage.chat.dto.ChatResponse;
import com.yundage.chat.entity.Conversation;
import com.yundage.chat.entity.Message;
import com.yundage.chat.entity.User;
import com.yundage.chat.service.ChatService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
@RestController
@RequestMapping("/qa")
@Tag(name = "智能问答", description = "聊天和深度研究接口")
public class ChatController {
@Autowired
private ChatService chatService;
@PostMapping("/ask")
@Operation(summary = "智能问答", description = "支持普通聊天和深度研究两种模式")
public ResponseEntity<ChatResponse> ask(
@Valid @RequestBody ChatRequest request,
org.springframework.security.core.Authentication authentication) {
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
try {
ChatResponse response = chatService.processChat(request, user);
return ResponseEntity.ok(response);
} catch (Exception e) {
// 错误处理
ChatResponse errorResponse = new ChatResponse();
errorResponse.setSuccess(false);
errorResponse.setAnswer("抱歉,处理您的请求时出现了错误:" + e.getMessage());
return ResponseEntity.ok(errorResponse);
}
}
@PostMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(summary = "智能问答流式输出", description = "支持流式输出的智能问答内容格式为Markdown")
public SseEmitter askStream(
@Valid @RequestBody ChatRequest request,
org.springframework.security.core.Authentication authentication) {
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
return chatService.processChatStream(request, user);
}
@GetMapping("/conversations")
@Operation(summary = "获取用户会话列表", description = "获取当前用户的所有活跃会话")
public ResponseEntity<ApiResponse<List<Conversation>>> getConversations(
org.springframework.security.core.Authentication authentication) {
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
try {
List<Conversation> conversations = chatService.getUserConversations(user.getId());
return ResponseEntity.ok(ApiResponse.success(conversations));
} catch (Exception e) {
return ResponseEntity.ok(ApiResponse.error("获取会话列表失败:" + e.getMessage()));
}
}
@GetMapping("/conversations/{conversationId}/messages")
@Operation(summary = "获取会话消息历史", description = "获取指定会话的所有消息")
public ResponseEntity<ApiResponse<List<Message>>> getConversationMessages(
@PathVariable Long conversationId,
org.springframework.security.core.Authentication authentication) {
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
try {
List<Message> messages = chatService.getConversationMessages(conversationId, user.getId());
return ResponseEntity.ok(ApiResponse.success(messages));
} catch (Exception e) {
return ResponseEntity.ok(ApiResponse.error("获取消息历史失败:" + e.getMessage()));
}
}
}

View File

@@ -0,0 +1,42 @@
package com.yundage.chat.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public class ChatRequest {
@NotBlank(message = "mode不能为空")
@Pattern(regexp = "^(chat|research)$", message = "mode只能是chat或research")
private String mode;
private String conversationId;
@NotBlank(message = "message不能为空")
private String message;
public ChatRequest() {}
public String getMode() {
return mode;
}
public void setMode(String mode) {
this.mode = mode;
}
public String getConversationId() {
return conversationId;
}
public void setConversationId(String conversationId) {
this.conversationId = conversationId;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}

View File

@@ -0,0 +1,121 @@
package com.yundage.chat.dto;
import java.util.List;
public class ChatResponse {
private boolean success;
private String conversationId;
private String messageId;
private String answer;
private List<ReferenceItem> references;
private String reportId;
private Integer tokensUsed;
private Long timeMs;
public ChatResponse() {
this.success = true;
}
public boolean isSuccess() {
return success;
}
public void setSuccess(boolean success) {
this.success = success;
}
public String getConversationId() {
return conversationId;
}
public void setConversationId(String conversationId) {
this.conversationId = conversationId;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getAnswer() {
return answer;
}
public void setAnswer(String answer) {
this.answer = answer;
}
public List<ReferenceItem> getReferences() {
return references;
}
public void setReferences(List<ReferenceItem> references) {
this.references = references;
}
public String getReportId() {
return reportId;
}
public void setReportId(String reportId) {
this.reportId = reportId;
}
public Integer getTokensUsed() {
return tokensUsed;
}
public void setTokensUsed(Integer tokensUsed) {
this.tokensUsed = tokensUsed;
}
public Long getTimeMs() {
return timeMs;
}
public void setTimeMs(Long timeMs) {
this.timeMs = timeMs;
}
public static class ReferenceItem {
private String title;
private String url;
private String quote;
public ReferenceItem() {}
public ReferenceItem(String title, String url, String quote) {
this.title = title;
this.url = url;
this.quote = quote;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getQuote() {
return quote;
}
public void setQuote(String quote) {
this.quote = quote;
}
}
}

View File

@@ -0,0 +1,133 @@
package com.yundage.chat.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class StreamResponse {
private String type;
private String conversationId;
private String messageId;
private String content;
private List<ChatResponse.ReferenceItem> references;
private String reportId;
private Integer tokensUsed;
private Long timeMs;
private boolean finished;
private String error;
public StreamResponse() {}
public static StreamResponse content(String content) {
StreamResponse response = new StreamResponse();
response.setType("content");
response.setContent(content);
response.setFinished(false);
return response;
}
public static StreamResponse finished(String conversationId, String messageId,
List<ChatResponse.ReferenceItem> references,
String reportId, Integer tokensUsed, Long timeMs) {
StreamResponse response = new StreamResponse();
response.setType("finished");
response.setConversationId(conversationId);
response.setMessageId(messageId);
response.setReferences(references);
response.setReportId(reportId);
response.setTokensUsed(tokensUsed);
response.setTimeMs(timeMs);
response.setFinished(true);
return response;
}
public static StreamResponse error(String error) {
StreamResponse response = new StreamResponse();
response.setType("error");
response.setError(error);
response.setFinished(true);
return response;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getConversationId() {
return conversationId;
}
public void setConversationId(String conversationId) {
this.conversationId = conversationId;
}
public String getMessageId() {
return messageId;
}
public void setMessageId(String messageId) {
this.messageId = messageId;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public List<ChatResponse.ReferenceItem> getReferences() {
return references;
}
public void setReferences(List<ChatResponse.ReferenceItem> references) {
this.references = references;
}
public String getReportId() {
return reportId;
}
public void setReportId(String reportId) {
this.reportId = reportId;
}
public Integer getTokensUsed() {
return tokensUsed;
}
public void setTokensUsed(Integer tokensUsed) {
this.tokensUsed = tokensUsed;
}
public Long getTimeMs() {
return timeMs;
}
public void setTimeMs(Long timeMs) {
this.timeMs = timeMs;
}
public boolean isFinished() {
return finished;
}
public void setFinished(boolean finished) {
this.finished = finished;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
}

View File

@@ -0,0 +1,104 @@
package com.yundage.chat.entity;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.annotation.Column;
import java.time.LocalDateTime;
@Table("conversations")
public class Conversation {
@Id(keyType = KeyType.Auto)
private Long id;
@Column("user_id")
private Long userId;
private String title;
@Column("model_version")
private String modelVersion;
@Column("chat_mode")
private String chatMode;
@Column("is_active")
private Integer isActive;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
public Conversation() {
this.isActive = 1;
this.chatMode = "chat";
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getModelVersion() {
return modelVersion;
}
public void setModelVersion(String modelVersion) {
this.modelVersion = modelVersion;
}
public String getChatMode() {
return chatMode;
}
public void setChatMode(String chatMode) {
this.chatMode = chatMode;
}
public Integer getIsActive() {
return isActive;
}
public void setIsActive(Integer isActive) {
this.isActive = isActive;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,99 @@
package com.yundage.chat.entity;
import com.mybatisflex.annotation.Id;
import com.mybatisflex.annotation.KeyType;
import com.mybatisflex.annotation.Table;
import com.mybatisflex.annotation.Column;
import java.time.LocalDateTime;
@Table("messages")
public class Message {
@Id(keyType = KeyType.Auto)
private Long id;
@Column("conversation_id")
private Long conversationId;
@Column("sequence_no")
private Integer sequenceNo;
private String role;
private String content;
private Integer tokens;
@Column("latency_ms")
private Integer latencyMs;
@Column("created_at")
private LocalDateTime createdAt;
public Message() {}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getConversationId() {
return conversationId;
}
public void setConversationId(Long conversationId) {
this.conversationId = conversationId;
}
public Integer getSequenceNo() {
return sequenceNo;
}
public void setSequenceNo(Integer sequenceNo) {
this.sequenceNo = sequenceNo;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public Integer getTokens() {
return tokens;
}
public void setTokens(Integer tokens) {
this.tokens = tokens;
}
public Integer getLatencyMs() {
return latencyMs;
}
public void setLatencyMs(Integer latencyMs) {
this.latencyMs = latencyMs;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,19 @@
package com.yundage.chat.mapper;
import com.mybatisflex.core.BaseMapper;
import com.yundage.chat.entity.Conversation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface ConversationMapper extends BaseMapper<Conversation> {
@Select("SELECT * FROM conversations WHERE user_id = #{userId} AND is_active = 1 ORDER BY updated_at DESC")
List<Conversation> findActiveByUserId(@Param("userId") Long userId);
@Select("SELECT * FROM conversations WHERE id = #{id} AND user_id = #{userId}")
Conversation findByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);
}

View File

@@ -0,0 +1,19 @@
package com.yundage.chat.mapper;
import com.mybatisflex.core.BaseMapper;
import com.yundage.chat.entity.Message;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface MessageMapper extends BaseMapper<Message> {
@Select("SELECT * FROM messages WHERE conversation_id = #{conversationId} ORDER BY sequence_no ASC")
List<Message> findByConversationId(@Param("conversationId") Long conversationId);
@Select("SELECT COALESCE(MAX(sequence_no), 0) FROM messages WHERE conversation_id = #{conversationId}")
Integer getMaxSequenceNo(@Param("conversationId") Long conversationId);
}

View File

@@ -0,0 +1,277 @@
package com.yundage.chat.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yundage.chat.dto.ChatRequest;
import com.yundage.chat.dto.ChatResponse;
import com.yundage.chat.dto.StreamResponse;
import com.yundage.chat.entity.Conversation;
import com.yundage.chat.entity.Message;
import com.yundage.chat.entity.User;
import com.yundage.chat.mapper.ConversationMapper;
import com.yundage.chat.mapper.MessageMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.concurrent.DelegatingSecurityContextRunnable;
import reactor.core.publisher.Flux;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@RequiredArgsConstructor
@Service
public class ChatService {
private ConversationMapper conversationMapper;
private MessageMapper messageMapper;
/** ChatClient 由 Spring AI Starter 自动装配;可注入 Builder 再 build(),也可直接注入 ChatClient */
private final ChatClient chatClient;
private final ObjectMapper mapper = new ObjectMapper();
private final ExecutorService executor = Executors.newCachedThreadPool();
// 👇 手写构造器完成所有 final 字段的注入
@Autowired
public ChatService(ChatClient.Builder builder,
ConversationMapper conversationMapper,
MessageMapper messageMapper) {
this.chatClient = builder.build();;
this.conversationMapper = conversationMapper;
this.messageMapper = messageMapper;
}
@Transactional
public SseEmitter processChatStream(ChatRequest request, User user) {
// 0 表示不超时;如需限制自行调整
SseEmitter emitter = new SseEmitter(0L);
try {
long start = System.currentTimeMillis();
// 1⃣ 取得会话并保存用户消息
Conversation convo = getOrCreateConversation(request, user);
saveUserMessage(convo.getId(), request.getMessage());
// 2⃣ 在独立线程里完成流处理,避免阻塞 servlet 线程
SecurityContext ctx = SecurityContextHolder.getContext();
executor.submit(new DelegatingSecurityContextRunnable(() -> {
StringBuilder full = new StringBuilder();
// 3⃣ 使用 Spring AI 流式接口;`content()` 把 Flux<ChatResponse> 映射成 Flux<String>
Flux<String> flux = chatClient
.prompt() // fluent API
.user(request.getMessage()) // 用户消息
.stream() // 进入流模式 [oai_citation:0‡Home](https://docs.spring.io/spring-ai/reference/api/chatclient.html)
.content(); // 只取内容字符串 [oai_citation:1‡danvega.dev](https://www.danvega.dev/blog/spring-ai-streaming-chatbot)
flux.doOnNext(chunk -> { // 每个 token/块回调
try {
full.append(chunk); // 累积完整内容
StreamResponse resp = StreamResponse.content(chunk);
emitter.send(SseEmitter.event()
.name("message")
.data(mapper.writeValueAsString(resp)));
} catch (Exception io) {
emitter.completeWithError(io);
}
})
.doOnComplete(() -> { // 4⃣ 模型输出结束
try {
Message assistantMsg = saveAssistantMessage(convo.getId(), full.toString());
convo.setUpdatedAt(LocalDateTime.now());
conversationMapper.update(convo);
List<ChatResponse.ReferenceItem> refs =
"research".equals(request.getMode()) ? generateMockReferences() : List.of();
StreamResponse finish = StreamResponse.finished(
convo.getId().toString(),
assistantMsg.getId().toString(),
refs,
null,
estimateTokens(request.getMessage(), full.toString()),
System.currentTimeMillis() - start
);
emitter.send(SseEmitter.event()
.name("finished")
.data(mapper.writeValueAsString(finish)));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
})
.doOnError(err -> { // 5⃣ 流异常
try {
StreamResponse error = StreamResponse.error("AI 处理失败:" + err.getMessage());
emitter.send(SseEmitter.event()
.name("error")
.data(mapper.writeValueAsString(error)));
} catch (Exception ignore) {}
emitter.completeWithError(err);
})
.subscribe(); // 最终触发流
}, ctx));
} catch (Exception ex) {
emitter.completeWithError(ex);
}
return emitter;
}
@Transactional
public ChatResponse processChat(ChatRequest request, User user) {
long startTime = System.currentTimeMillis();
// 获取或创建会话
Conversation conversation = getOrCreateConversation(request, user);
// 保存用户消息
Message userMessage = saveUserMessage(conversation.getId(), request.getMessage());
// 模拟AI回复生成 - 这里可以接入实际的AI服务
String aiResponse = generateAIResponse(request);
// 保存AI回复消息
Message assistantMessage = saveAssistantMessage(conversation.getId(), aiResponse);
// 更新会话的更新时间
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.update(conversation);
// 构建响应
ChatResponse response = new ChatResponse();
response.setConversationId(conversation.getId().toString());
response.setMessageId(assistantMessage.getId().toString());
response.setAnswer(aiResponse);
response.setTimeMs(System.currentTimeMillis() - startTime);
// 根据模式设置不同的响应内容
if ("research".equals(request.getMode())) {
// 研究模式可能包含引用资料
response.setReferences(generateMockReferences());
response.setReportId(null); // 可以在后续版本中实现报告生成功能
} else {
// 普通聊天模式
response.setReferences(new ArrayList<>());
response.setReportId(null);
}
// 模拟token统计
response.setTokensUsed(estimateTokens(request.getMessage(), aiResponse));
return response;
}
private String[] generateStreamingAIResponse(ChatRequest request) {
// 目前先用单段文本模拟,后续可改为 chatClient.stream() 真正流式
String aiReply = "这是AI的回复内容模拟流式输出。";
return new String[]{aiReply};
}
private Conversation getOrCreateConversation(ChatRequest request, User user) {
if (request.getConversationId() != null) {
// 尝试获取现有会话
try {
Long conversationId = Long.parseLong(request.getConversationId());
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, user.getId());
if (conversation != null) {
return conversation;
}
} catch (NumberFormatException e) {
// 忽略无效的会话ID创建新会话
}
}
// 创建新会话
Conversation conversation = new Conversation();
conversation.setUserId(user.getId());
conversation.setChatMode(request.getMode());
conversation.setTitle(generateConversationTitle(request.getMessage()));
conversation.setCreatedAt(LocalDateTime.now());
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.insert(conversation);
return conversation;
}
private Message saveUserMessage(Long conversationId, String content) {
Integer nextSequenceNo = messageMapper.getMaxSequenceNo(conversationId) + 1;
Message message = new Message();
message.setConversationId(conversationId);
message.setSequenceNo(nextSequenceNo);
message.setRole("user");
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
messageMapper.insert(message);
return message;
}
private Message saveAssistantMessage(Long conversationId, String content) {
Integer nextSequenceNo = messageMapper.getMaxSequenceNo(conversationId) + 1;
Message message = new Message();
message.setConversationId(conversationId);
message.setSequenceNo(nextSequenceNo);
message.setRole("assistant");
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
messageMapper.insert(message);
return message;
}
private String generateAIResponse(ChatRequest request) {
// 使用 Spring AI ChatClient 生成回复
return "这是AI的回复内容模拟生成。";
}
private String generateConversationTitle(String message) {
// 简单的标题生成逻辑取前20个字符
if (message.length() <= 20) {
return message;
}
return message.substring(0, 20) + "...";
}
private List<ChatResponse.ReferenceItem> generateMockReferences() {
// 模拟的引用资料,实际项目中从真实的知识库获取
List<ChatResponse.ReferenceItem> references = new ArrayList<>();
references.add(new ChatResponse.ReferenceItem(
"相关研究文档",
"https://example.com/research",
"这是一段引用的文本内容..."
));
return references;
}
private Integer estimateTokens(String userMessage, String aiResponse) {
// 简单的token估算实际项目中需要使用真实的tokenizer
return (userMessage.length() + aiResponse.length()) / 4;
}
public List<Conversation> getUserConversations(Long userId) {
return conversationMapper.findActiveByUserId(userId);
}
public List<Message> getConversationMessages(Long conversationId, Long userId) {
// 验证会话是否属于用户
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, userId);
if (conversation == null) {
throw new RuntimeException("会话不存在或无权限访问");
}
return messageMapper.findByConversationId(conversationId);
}
}

View File

@@ -17,7 +17,15 @@ spring:
auth: true auth: true
starttls: starttls:
enable: true enable: true
ai:
openai:
api-key: sk-5gAxnpuwJ9KIsea51cD05e21F1Ac4b6a99C8A97278C1F837
base-url: https://www.jzhengda-api.com
chat:
options:
model: gpt-4o-mini
security:
debug: true
# MyBatis-Flex configuration # MyBatis-Flex configuration
mybatis-flex: mybatis-flex:
# Enable SQL logging # Enable SQL logging
@@ -57,6 +65,7 @@ springdoc:
logging: logging:
level: level:
com.yundage.chat: debug com.yundage.chat: debug
com.mybatisflex: debug com.mybatisflex: info
org.springframework.security: DEBUG
# org.springframework: DEBUG # org.springframework: DEBUG
#debug: true #debug: true