diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a7d4b1d..1068ba4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,10 @@ "Bash(cd:*)", "Bash(ls:*)", "Bash(find:*)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(chmod:*)", + "Bash(docker build:*)" ], "deny": [] } diff --git a/.idea/compiler.xml b/.idea/compiler.xml index a0558ad..c1970b3 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -2,6 +2,7 @@ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f5694e0 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..44e90d0 --- /dev/null +++ b/deploy.sh @@ -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." \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..586748e --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docs/chat.md b/docs/chat.md new file mode 100644 index 0000000..20b1402 --- /dev/null +++ b/docs/chat.md @@ -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": "LLM(Large 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、数据库如何存储这些内容,欢迎继续提问。 \ No newline at end of file diff --git a/pom.xml b/pom.xml index a5d3e78..8065556 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ org.springframework.boot spring-boot-starter-parent - 3.0.4 + 3.2.4 @@ -108,6 +108,17 @@ springdoc-openapi-starter-webmvc-ui 2.5.0 + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + 1.0.0-M6 + + + org.projectlombok + lombok + provided + diff --git a/src/main/java/com/yundage/chat/config/SecurityConfig.java b/src/main/java/com/yundage/chat/config/SecurityConfig.java index 0222d0f..15606ae 100644 --- a/src/main/java/com/yundage/chat/config/SecurityConfig.java +++ b/src/main/java/com/yundage/chat/config/SecurityConfig.java @@ -3,15 +3,18 @@ package com.yundage.chat.config; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; 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.method.configuration.EnableMethodSecurity; 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.configurers.AbstractHttpConfigurer; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -22,23 +25,25 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.Arrays; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { - + @Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; - + @Autowired private UserDetailsService userDetailsService; - + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - + @Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); @@ -46,12 +51,12 @@ public class SecurityConfig { authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } - + @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } - + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); @@ -63,35 +68,68 @@ public class SecurityConfig { source.registerCorsConfiguration("/**", configuration); return source; } - + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + // 1. CORS 和 CSRF 配置 .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) + + // 2. 路由权限配置 .authorizeHttpRequests(authz -> authz - // 认证相关接口 + // 允许所有 OPTIONS 请求(CORS 预检) + .requestMatchers(HttpMethod.OPTIONS, "/**").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() - // Swagger API文档 - .requestMatchers("/swagger-ui/**").permitAll() - .requestMatchers("/swagger-ui.html").permitAll() - .requestMatchers("/api-docs/**").permitAll() - .requestMatchers("/v3/api-docs/**").permitAll() - .requestMatchers("/swagger-resources/**").permitAll() - .requestMatchers("/webjars/**").permitAll() - // 其他所有请求需要认证 + + // 公开接口 + .requestMatchers("/api/public/**").permitAll() + + // 聊天接口(允许流式访问) + .requestMatchers(HttpMethod.POST, "/qa/ask/stream").permitAll() + + // 聊天相关接口需认证 + .requestMatchers("/qa/**").authenticated() + + // 所有其他请求需认证 .anyRequest().authenticated() ) + + // 3. 无状态会话配置(适用于 JWT) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) + + // 4. 认证提供者与 JWT 过滤器 .authenticationProvider(authenticationProvider()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - + + // 设置安全上下文策略(适用于多线程环境) + SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL); + return http.build(); } + + @Bean + public ExecutorService securityContextExecutorService() { + // 创建一个固定大小的线程池,并使用DelegatingSecurityContextExecutorService包装 + // 这样线程池中的线程会自动继承安全上下文 + ExecutorService executorService = Executors.newFixedThreadPool(10); + return new DelegatingSecurityContextExecutorService(executorService); + } } \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/controller/ChatController.java b/src/main/java/com/yundage/chat/controller/ChatController.java new file mode 100644 index 0000000..36d7713 --- /dev/null +++ b/src/main/java/com/yundage/chat/controller/ChatController.java @@ -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 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>> getConversations( + org.springframework.security.core.Authentication authentication) { + + com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal(); + + try { + List 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>> 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 messages = chatService.getConversationMessages(conversationId, user.getId()); + return ResponseEntity.ok(ApiResponse.success(messages)); + } catch (Exception e) { + return ResponseEntity.ok(ApiResponse.error("获取消息历史失败:" + e.getMessage())); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/dto/ChatRequest.java b/src/main/java/com/yundage/chat/dto/ChatRequest.java new file mode 100644 index 0000000..3701447 --- /dev/null +++ b/src/main/java/com/yundage/chat/dto/ChatRequest.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/dto/ChatResponse.java b/src/main/java/com/yundage/chat/dto/ChatResponse.java new file mode 100644 index 0000000..47bcb89 --- /dev/null +++ b/src/main/java/com/yundage/chat/dto/ChatResponse.java @@ -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 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 getReferences() { + return references; + } + + public void setReferences(List 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; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/dto/StreamResponse.java b/src/main/java/com/yundage/chat/dto/StreamResponse.java new file mode 100644 index 0000000..3c3ff70 --- /dev/null +++ b/src/main/java/com/yundage/chat/dto/StreamResponse.java @@ -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 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 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 getReferences() { + return references; + } + + public void setReferences(List 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/entity/Conversation.java b/src/main/java/com/yundage/chat/entity/Conversation.java new file mode 100644 index 0000000..25cabb1 --- /dev/null +++ b/src/main/java/com/yundage/chat/entity/Conversation.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/entity/Message.java b/src/main/java/com/yundage/chat/entity/Message.java new file mode 100644 index 0000000..b4454b3 --- /dev/null +++ b/src/main/java/com/yundage/chat/entity/Message.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/mapper/ConversationMapper.java b/src/main/java/com/yundage/chat/mapper/ConversationMapper.java new file mode 100644 index 0000000..f24a34e --- /dev/null +++ b/src/main/java/com/yundage/chat/mapper/ConversationMapper.java @@ -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 { + + @Select("SELECT * FROM conversations WHERE user_id = #{userId} AND is_active = 1 ORDER BY updated_at DESC") + List 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); +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/mapper/MessageMapper.java b/src/main/java/com/yundage/chat/mapper/MessageMapper.java new file mode 100644 index 0000000..2f64be3 --- /dev/null +++ b/src/main/java/com/yundage/chat/mapper/MessageMapper.java @@ -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 { + + @Select("SELECT * FROM messages WHERE conversation_id = #{conversationId} ORDER BY sequence_no ASC") + List findByConversationId(@Param("conversationId") Long conversationId); + + @Select("SELECT COALESCE(MAX(sequence_no), 0) FROM messages WHERE conversation_id = #{conversationId}") + Integer getMaxSequenceNo(@Param("conversationId") Long conversationId); +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/ChatService.java b/src/main/java/com/yundage/chat/service/ChatService.java new file mode 100644 index 0000000..22f7f5d --- /dev/null +++ b/src/main/java/com/yundage/chat/service/ChatService.java @@ -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 映射成 Flux + Flux 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 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 generateMockReferences() { + // 模拟的引用资料,实际项目中从真实的知识库获取 + List 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 getUserConversations(Long userId) { + return conversationMapper.findActiveByUserId(userId); + } + + public List getConversationMessages(Long conversationId, Long userId) { + // 验证会话是否属于用户 + Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, userId); + if (conversation == null) { + throw new RuntimeException("会话不存在或无权限访问"); + } + + return messageMapper.findByConversationId(conversationId); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 59e53df..1d74b7b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,7 +17,15 @@ spring: auth: true starttls: 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: # Enable SQL logging @@ -57,6 +65,7 @@ springdoc: logging: level: com.yundage.chat: debug - com.mybatisflex: debug + com.mybatisflex: info + org.springframework.security: DEBUG # org.springframework: DEBUG #debug: true \ No newline at end of file