Merge remote-tracking branch 'origin/master'
This commit is contained in:
23
docs/忘记密码和重置密码.md
Normal file
23
docs/忘记密码和重置密码.md
Normal file
@@ -0,0 +1,23 @@
|
||||
"忘记密码"(Forgot Password)和"重置密码"(Reset Password)是密码恢复流程的两个不同步骤:
|
||||
|
||||
1. 忘记密码(forgotPassword):
|
||||
|
||||
- 流程的第一步
|
||||
|
||||
- 当用户表示忘记密码时触发
|
||||
|
||||
- 系统向用户发送带有重置令牌的邮件
|
||||
|
||||
- 不直接更改密码
|
||||
|
||||
1. 重置密码(resetPassword):
|
||||
|
||||
- 流程的第二步
|
||||
|
||||
- 用户点击邮件中的链接或输入令牌后使用
|
||||
|
||||
- 验证令牌并允许用户设置新密码
|
||||
|
||||
- 实际修改用户密码的步骤
|
||||
|
||||
简单来说,忘记密码是发起恢复流程,重置密码是完成密码更改。
|
||||
14
pom.xml
14
pom.xml
@@ -119,6 +119,13 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.projectreactor.netty</groupId>
|
||||
<artifactId>reactor-netty-http</artifactId>
|
||||
<version>1.1.15</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -127,6 +134,13 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -1,13 +1,14 @@
|
||||
package com.yundage.chat;
|
||||
|
||||
import com.yundage.chat.config.LlmApiProperties;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
|
||||
|
||||
@SpringBootApplication(exclude = {HibernateJpaAutoConfiguration.class})
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.yundage.chat.mapper")
|
||||
@EnableConfigurationProperties(LlmApiProperties.class)
|
||||
public class ChatApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -64,6 +64,10 @@ public class ApiResponse<T> {
|
||||
return new ApiResponse<>(ErrorCode.NOT_FOUND, message);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> conflict(String message) {
|
||||
return new ApiResponse<>(ErrorCode.CONFLICT, message);
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public int getCode() {
|
||||
return code;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package com.yundage.chat.common;
|
||||
|
||||
public class ErrorCode {
|
||||
|
||||
// 通用错误码
|
||||
// 成功
|
||||
public static final int SUCCESS = 200;
|
||||
|
||||
// 客户端错误
|
||||
public static final int BAD_REQUEST = 400;
|
||||
public static final int UNAUTHORIZED = 401;
|
||||
public static final int FORBIDDEN = 403;
|
||||
public static final int NOT_FOUND = 404;
|
||||
public static final int CONFLICT = 409;
|
||||
|
||||
// 服务端错误
|
||||
public static final int INTERNAL_ERROR = 500;
|
||||
public static final int SERVICE_UNAVAILABLE = 503;
|
||||
|
||||
// 认证相关错误码 (1000-1999)
|
||||
public static final int VERIFICATION_CODE_INVALID = 1001;
|
||||
|
||||
28
src/main/java/com/yundage/chat/config/LlmApiProperties.java
Normal file
28
src/main/java/com/yundage/chat/config/LlmApiProperties.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.yundage.chat.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* LLM API配置属性类
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "llm")
|
||||
public class LlmApiProperties {
|
||||
private String baseUrl;
|
||||
private String apiKey; // 可选,预留未来认证使用
|
||||
|
||||
public String getBaseUrl() {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public void setBaseUrl(String baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.yundage.chat.config;
|
||||
|
||||
import io.netty.handler.logging.LogLevel;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
import reactor.netty.transport.logging.AdvancedByteBufFormat;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class LlmWebClientConfig {
|
||||
@Value(
|
||||
"${llm.base-url:}"
|
||||
)
|
||||
private String baseUrl;
|
||||
@Bean("llmWebClient")
|
||||
public WebClient llmWebClient(
|
||||
WebClient.Builder builder) {
|
||||
|
||||
return builder
|
||||
.baseUrl(baseUrl) // ← 确认是 FastAPI 的实际端口
|
||||
.clientConnector(new ReactorClientHttpConnector(
|
||||
HttpClient.create()
|
||||
.responseTimeout(Duration.ofSeconds(60))
|
||||
.wiretap("reactor.netty.http.client.HttpClient",
|
||||
LogLevel.DEBUG,
|
||||
AdvancedByteBufFormat.TEXTUAL)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ public class OpenApiConfig {
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("云搭公服对话API")
|
||||
.description("云搭公服对话系统API文档")
|
||||
.title("云大所智能问答")
|
||||
.description("云大所智能问答系统API文档")
|
||||
.version("v1.0.0")
|
||||
.contact(new Contact()
|
||||
.name("Yundage")
|
||||
|
||||
@@ -93,6 +93,21 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
@Operation(summary = "用户登出", description = "退出用户登录状态")
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "登出成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "登出失败")
|
||||
})
|
||||
public ApiResponse<?> logout() {
|
||||
try {
|
||||
userService.logout();
|
||||
return ApiResponse.success("登出成功");
|
||||
} catch (Exception e) {
|
||||
return ApiResponse.error(ErrorCode.INTERNAL_ERROR, "登出失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/forgot-password")
|
||||
@Operation(summary = "忘记密码", description = "发送密码重置邮件")
|
||||
@ApiResponses(value = {
|
||||
|
||||
@@ -87,4 +87,24 @@ public class ChatController {
|
||||
return ResponseEntity.ok(ApiResponse.error("获取消息历史失败:" + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/conversations/{conversationId}")
|
||||
@Operation(summary = "删除会话", description = "删除指定ID的会话")
|
||||
public ResponseEntity<ApiResponse<String>> deleteConversation(
|
||||
@PathVariable Long conversationId,
|
||||
org.springframework.security.core.Authentication authentication) {
|
||||
|
||||
com.yundage.chat.entity.User user = (com.yundage.chat.entity.User) authentication.getPrincipal();
|
||||
|
||||
try {
|
||||
boolean result = chatService.deleteConversation(conversationId, user.getId());
|
||||
if (result) {
|
||||
return ResponseEntity.ok(ApiResponse.success("会话删除成功"));
|
||||
} else {
|
||||
return ResponseEntity.ok(ApiResponse.error("会话不存在或无权限删除"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.ok(ApiResponse.error("删除会话失败:" + e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
package com.yundage.chat.controller;
|
||||
|
||||
import com.yundage.chat.common.ApiResponse;
|
||||
import com.yundage.chat.dto.UserDTO;
|
||||
import com.yundage.chat.dto.UserProfileUpdateRequest;
|
||||
import com.yundage.chat.entity.User;
|
||||
import com.yundage.chat.mapper.UserMapper;
|
||||
import com.yundage.chat.service.UserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -23,57 +29,120 @@ public class UserController {
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "获取所有用户", description = "获取系统中所有用户列表")
|
||||
@ApiResponse(responseCode = "200", description = "成功获取用户列表")
|
||||
public List<User> getAllUsers() {
|
||||
return userMapper.selectAll();
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功获取用户列表")
|
||||
public ApiResponse<List<User>> getAllUsers() {
|
||||
return ApiResponse.success(userMapper.selectAll());
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
@Operation(summary = "获取当前用户信息", description = "根据当前用户的token获取用户信息")
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功获取用户信息"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未授权")
|
||||
})
|
||||
public ApiResponse<UserDTO> getCurrentUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
String username = authentication.getName();
|
||||
User user = userMapper.selectByEmailOrPhone(username);
|
||||
return ApiResponse.success(UserDTO.fromUser(user));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "根据ID获取用户", description = "根据用户ID获取用户信息")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "成功获取用户信息"),
|
||||
@ApiResponse(responseCode = "404", description = "用户不存在")
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功获取用户信息"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "用户不存在")
|
||||
})
|
||||
public User getUserById(@Parameter(description = "用户ID") @PathVariable Long id) {
|
||||
return userMapper.selectOneById(id);
|
||||
public ApiResponse<User> getUserById(@Parameter(description = "用户ID") @PathVariable Long id) {
|
||||
User user = userMapper.selectOneById(id);
|
||||
if (user != null) {
|
||||
return ApiResponse.success(user);
|
||||
} else {
|
||||
return ApiResponse.notFound("用户不存在");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "创建用户", description = "创建新的用户")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "用户创建成功"),
|
||||
@ApiResponse(responseCode = "400", description = "请求参数错误")
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "用户创建成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数错误")
|
||||
})
|
||||
public User createUser(@RequestBody User user) {
|
||||
public ApiResponse<User> createUser(@RequestBody User user) {
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
userMapper.insert(user);
|
||||
return user;
|
||||
return ApiResponse.success(user);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新用户", description = "更新指定用户的信息")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "更新用户", description = "管理员更新指定用户的信息")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "用户更新成功"),
|
||||
@ApiResponse(responseCode = "404", description = "用户不存在"),
|
||||
@ApiResponse(responseCode = "400", description = "请求参数错误")
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "用户更新成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "用户不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数错误")
|
||||
})
|
||||
public User updateUser(@Parameter(description = "用户ID") @PathVariable Long id, @RequestBody User user) {
|
||||
public ApiResponse<User> updateUser(@Parameter(description = "用户ID") @PathVariable Long id, @RequestBody User user) {
|
||||
User existingUser = userMapper.selectOneById(id);
|
||||
if (existingUser == null) {
|
||||
return ApiResponse.notFound("用户不存在");
|
||||
}
|
||||
user.setId(id);
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
userMapper.update(user);
|
||||
return user;
|
||||
return ApiResponse.success(user);
|
||||
}
|
||||
|
||||
@PutMapping("/profile")
|
||||
@Operation(summary = "更新个人资料", description = "普通用户更新自己的个人资料")
|
||||
@ApiResponses(value = {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "个人资料更新成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数错误"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "未授权"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "邮箱或手机号已被占用")
|
||||
})
|
||||
public ApiResponse<UserDTO> updateProfile(@RequestBody UserProfileUpdateRequest request) {
|
||||
try {
|
||||
// 获取当前用户
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
User currentUser = (User) authentication.getPrincipal();
|
||||
|
||||
// 调用服务更新用户资料
|
||||
UserDTO updatedUser = userService.updateCurrentUserProfile(request, currentUser.getId());
|
||||
|
||||
return ApiResponse.success(updatedUser);
|
||||
} catch (RuntimeException e) {
|
||||
// 处理可能的错误情况
|
||||
if (e.getMessage().contains("已被其他用户使用")) {
|
||||
return ApiResponse.conflict(e.getMessage());
|
||||
} else {
|
||||
return ApiResponse.badRequest(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "删除用户", description = "根据ID删除用户")
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "用户删除成功"),
|
||||
@ApiResponse(responseCode = "404", description = "用户不存在")
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "用户删除成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "用户不存在")
|
||||
})
|
||||
public void deleteUser(@Parameter(description = "用户ID") @PathVariable Long id) {
|
||||
public ApiResponse<Void> deleteUser(@Parameter(description = "用户ID") @PathVariable Long id) {
|
||||
User existingUser = userMapper.selectOneById(id);
|
||||
if (existingUser == null) {
|
||||
return ApiResponse.notFound("用户不存在");
|
||||
}
|
||||
userMapper.deleteById(id);
|
||||
return ApiResponse.success();
|
||||
}
|
||||
}
|
||||
58
src/main/java/com/yundage/chat/dto/UserDTO.java
Normal file
58
src/main/java/com/yundage/chat/dto/UserDTO.java
Normal file
@@ -0,0 +1,58 @@
|
||||
package com.yundage.chat.dto;
|
||||
|
||||
import com.yundage.chat.entity.User;
|
||||
import com.yundage.chat.enums.UserType;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "用户数据传输对象")
|
||||
public class UserDTO {
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "用户类型")
|
||||
private UserType userType;
|
||||
|
||||
@Schema(description = "头像URL")
|
||||
private String avatarUrl;
|
||||
|
||||
@Schema(description = "最后登录时间")
|
||||
private String lastLoginAt;
|
||||
|
||||
/**
|
||||
* 将User实体转换为UserDTO
|
||||
*
|
||||
* @param user User实体
|
||||
* @return UserDTO对象
|
||||
*/
|
||||
public static UserDTO fromUser(User user) {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
UserDTO dto = new UserDTO();
|
||||
dto.setId(user.getId());
|
||||
dto.setUsername(user.getUsername());
|
||||
dto.setEmail(user.getEmail());
|
||||
dto.setPhone(user.getPhone());
|
||||
dto.setUserType(user.getUserType());
|
||||
dto.setAvatarUrl(user.getAvatarUrl());
|
||||
|
||||
if (user.getLastLoginAt() != null) {
|
||||
dto.setLastLoginAt(user.getLastLoginAt().toString());
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yundage.chat.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 用户个人资料更新请求DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "用户个人资料更新请求")
|
||||
public class UserProfileUpdateRequest {
|
||||
|
||||
@Schema(description = "用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "头像URL")
|
||||
private String avatarUrl;
|
||||
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
}
|
||||
@@ -2,8 +2,16 @@ package com.yundage.chat.mapper;
|
||||
|
||||
import com.yundage.chat.entity.User;
|
||||
import com.mybatisflex.core.BaseMapper;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
@Mapper
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
|
||||
default User selectByEmailOrPhone(String username) {
|
||||
QueryWrapper queryWrapper = new QueryWrapper();
|
||||
queryWrapper.where("email = ? OR phone = ?", username, username).limit(1);
|
||||
return selectOneByQuery(queryWrapper);
|
||||
}
|
||||
}
|
||||
@@ -1,277 +1,55 @@
|
||||
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;
|
||||
/**
|
||||
* 聊天服务接口
|
||||
*/
|
||||
public interface ChatService {
|
||||
|
||||
/**
|
||||
* 处理流式聊天请求
|
||||
* @param request 聊天请求
|
||||
* @param user 当前用户
|
||||
* @return SSE发射器
|
||||
*/
|
||||
SseEmitter processChatStream(ChatRequest request, User user);
|
||||
|
||||
private MessageMapper messageMapper;
|
||||
/**
|
||||
* 处理普通聊天请求
|
||||
* @param request 聊天请求
|
||||
* @param user 当前用户
|
||||
* @return 聊天响应
|
||||
*/
|
||||
ChatResponse processChat(ChatRequest request, User user);
|
||||
|
||||
/**
|
||||
* 获取用户的会话列表
|
||||
* @param userId 用户ID
|
||||
* @return 会话列表
|
||||
*/
|
||||
List<Conversation> getUserConversations(Long userId);
|
||||
|
||||
/** ChatClient 由 Spring AI Starter 自动装配;可注入 Builder 再 build(),也可直接注入 ChatClient */
|
||||
private final ChatClient chatClient;
|
||||
/**
|
||||
* 获取会话消息历史
|
||||
* @param conversationId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return 消息列表
|
||||
*/
|
||||
List<Message> getConversationMessages(Long conversationId, Long userId);
|
||||
|
||||
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);
|
||||
}
|
||||
/**
|
||||
* 删除用户会话
|
||||
* @param conversationId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
boolean deleteConversation(Long conversationId, Long userId);
|
||||
}
|
||||
@@ -1,46 +1,15 @@
|
||||
package com.yundage.chat.service;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
/**
|
||||
* 邮件服务接口
|
||||
*/
|
||||
public interface EmailService {
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
@Autowired
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
@Value("${app.reset-password-url:http://localhost:3000/reset-password}")
|
||||
private String resetPasswordUrl;
|
||||
|
||||
public void sendPasswordResetEmail(String toEmail, String username, String token) {
|
||||
try {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(fromEmail);
|
||||
message.setTo(toEmail);
|
||||
message.setSubject("密码重置 - 云大AI聊天");
|
||||
|
||||
String resetLink = resetPasswordUrl + "?token=" + token;
|
||||
String emailBody = String.format(
|
||||
"亲爱的 %s,\n\n" +
|
||||
"我们收到了您的密码重置请求。请点击以下链接重置您的密码:\n\n" +
|
||||
"%s\n\n" +
|
||||
"此链接将在1小时后过期。如果您没有请求重置密码,请忽略此邮件。\n\n" +
|
||||
"祝好,\n" +
|
||||
"云大AI聊天团队",
|
||||
username, resetLink
|
||||
);
|
||||
|
||||
message.setText(emailBody);
|
||||
mailSender.send(message);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("发送邮件失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 发送密码重置邮件
|
||||
* @param toEmail 接收邮件的邮箱
|
||||
* @param username 用户名
|
||||
* @param token 重置密码令牌
|
||||
*/
|
||||
void sendPasswordResetEmail(String toEmail, String username, String token);
|
||||
}
|
||||
32
src/main/java/com/yundage/chat/service/LLMService.java
Normal file
32
src/main/java/com/yundage/chat/service/LLMService.java
Normal file
@@ -0,0 +1,32 @@
|
||||
package com.yundage.chat.service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 大语言模型服务接口
|
||||
* 定义与大语言模型交互的通用方法,方便替换不同的模型实现
|
||||
*/
|
||||
public interface LLMService {
|
||||
/**
|
||||
* 处理流式文本生成请求
|
||||
* @param userMessage 用户消息
|
||||
* @param parameters 额外的参数,如温度、最大长度等
|
||||
* @param callback 接收每个文本块的回调函数
|
||||
* @param onComplete 完成时的回调函数
|
||||
* @param onError 错误时的回调函数
|
||||
*/
|
||||
void streamGenerateText(String userMessage,
|
||||
Map<String, Object> parameters,
|
||||
Consumer<String> callback,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError);
|
||||
|
||||
/**
|
||||
* 处理非流式文本生成请求
|
||||
* @param userMessage 用户消息
|
||||
* @param parameters 额外的参数,如温度、最大长度等
|
||||
* @return 生成的文本
|
||||
*/
|
||||
String generateText(String userMessage, Map<String, Object> parameters);
|
||||
}
|
||||
@@ -2,291 +2,90 @@ package com.yundage.chat.service;
|
||||
|
||||
import com.yundage.chat.dto.*;
|
||||
import com.yundage.chat.entity.User;
|
||||
import com.yundage.chat.entity.PasswordResetToken;
|
||||
import com.yundage.chat.mapper.UserMapper;
|
||||
import com.yundage.chat.mapper.PasswordResetTokenMapper;
|
||||
import com.yundage.chat.util.JwtUtil;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Autowired
|
||||
private PasswordResetTokenMapper tokenMapper;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Autowired
|
||||
private AuthenticationManager authenticationManager;
|
||||
|
||||
@Autowired
|
||||
private EmailService emailService;
|
||||
|
||||
// 从配置文件中获取验证码配置
|
||||
@Value("${app.verification-code.master-code:123456}")
|
||||
private String masterCode;
|
||||
|
||||
@Value("${app.verification-code.expiration:300}")
|
||||
private int codeExpirationSeconds;
|
||||
|
||||
@Value("${app.verification-code.development-mode:false}")
|
||||
private boolean developmentMode;
|
||||
|
||||
// 验证码存储,实际项目中应该使用Redis
|
||||
private final Map<String, VerificationCodeInfo> verificationCodes = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* 用户服务接口
|
||||
*/
|
||||
public interface UserService {
|
||||
|
||||
/**
|
||||
* 发送验证码
|
||||
* @param contact 联系方式(邮箱或手机号)
|
||||
* @return 验证码(仅开发模式返回)
|
||||
*/
|
||||
public String sendVerificationCode(String contact) {
|
||||
// 生成6位随机验证码
|
||||
String code = generateRandomCode();
|
||||
|
||||
// 存储验证码
|
||||
verificationCodes.put(contact, new VerificationCodeInfo(code, LocalDateTime.now()));
|
||||
|
||||
// TODO: 实际项目中,这里应该通过短信或邮件发送验证码
|
||||
// 如果是邮箱,可以使用邮件服务发送
|
||||
if (contact.contains("@")) {
|
||||
// emailService.sendVerificationCode(contact, code);
|
||||
} else {
|
||||
// 使用短信服务发送
|
||||
// smsService.sendVerificationCode(contact, code);
|
||||
}
|
||||
|
||||
// 在开发模式下,在控制台打印验证码
|
||||
if (developmentMode) {
|
||||
System.out.println("向 " + contact + " 发送验证码: " + code);
|
||||
}
|
||||
|
||||
return developmentMode ? code : null; // 仅在开发模式下返回验证码
|
||||
}
|
||||
String sendVerificationCode(String contact);
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* @param contact 联系方式
|
||||
* @param code 验证码
|
||||
* @return 是否验证成功
|
||||
* 注册用户
|
||||
* @param request 注册请求
|
||||
* @return 认证响应
|
||||
*/
|
||||
private boolean verifyCode(String contact, String code) {
|
||||
// 检查万能验证码
|
||||
if (masterCode.equals(code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查验证码是否存在
|
||||
VerificationCodeInfo storedInfo = verificationCodes.get(contact);
|
||||
if (storedInfo == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查验证码是否过期
|
||||
LocalDateTime expirationTime = storedInfo.timestamp.plusSeconds(codeExpirationSeconds);
|
||||
if (LocalDateTime.now().isAfter(expirationTime)) {
|
||||
verificationCodes.remove(contact); // 移除过期验证码
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查验证码是否匹配
|
||||
boolean isValid = storedInfo.code.equals(code);
|
||||
if (isValid) {
|
||||
verificationCodes.remove(contact); // 验证成功后移除验证码,防止重复使用
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
// 验证验证码
|
||||
if (!verifyCode(request.getEmail() != null ? request.getEmail() : request.getPhone(), request.getCode())) {
|
||||
throw new RuntimeException("验证码无效或已过期");
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
if (request.getEmail() != null && existsByEmail(request.getEmail())) {
|
||||
throw new RuntimeException("邮箱已被注册");
|
||||
}
|
||||
|
||||
if (request.getPhone() != null && existsByPhone(request.getPhone())) {
|
||||
throw new RuntimeException("手机号已被注册");
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
User user = new User();
|
||||
user.setUsername(request.getUsername());
|
||||
user.setEmail(request.getEmail());
|
||||
user.setPhone(request.getPhone());
|
||||
// 设置一个随机密码,因为不再使用密码登录
|
||||
user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
userMapper.insert(user);
|
||||
|
||||
// 生成JWT令牌
|
||||
String token = jwtUtil.generateToken(user.getDisplayName(), user.getId());
|
||||
|
||||
return new AuthResponse(token, user.getId(), user.getDisplayName(),
|
||||
user.getEmail(), user.getPhone());
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
// 验证验证码
|
||||
if (!verifyCode(request.getContact(), request.getCode())) {
|
||||
throw new RuntimeException("验证码无效或已过期");
|
||||
}
|
||||
|
||||
// 根据联系方式查找用户
|
||||
User user = findByContact(request.getContact());
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 手动设置认证信息
|
||||
Authentication authentication = createAuthenticationToken(user);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
// 更新最后登录时间
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
userMapper.update(user);
|
||||
|
||||
// 生成JWT令牌
|
||||
String token = jwtUtil.generateToken(user.getDisplayName(), user.getId());
|
||||
|
||||
return new AuthResponse(token, user.getId(), user.getDisplayName(),
|
||||
user.getEmail(), user.getPhone());
|
||||
}
|
||||
AuthResponse register(RegisterRequest request);
|
||||
|
||||
/**
|
||||
* 创建认证令牌
|
||||
* 用户登录
|
||||
* @param request 登录请求
|
||||
* @return 认证响应
|
||||
*/
|
||||
private Authentication createAuthenticationToken(User user) {
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
return new UsernamePasswordAuthenticationToken(user, null, authorities);
|
||||
}
|
||||
AuthResponse login(LoginRequest request);
|
||||
|
||||
@Transactional
|
||||
public void requestPasswordReset(PasswordResetRequest request) {
|
||||
User user = findByEmail(request.getEmail());
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
void logout();
|
||||
|
||||
// 生成重置令牌
|
||||
String token = UUID.randomUUID().toString();
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusHours(1); // 1小时后过期
|
||||
/**
|
||||
* 请求密码重置
|
||||
* @param request 密码重置请求
|
||||
*/
|
||||
void requestPasswordReset(PasswordResetRequest request);
|
||||
|
||||
PasswordResetToken resetToken = new PasswordResetToken(user.getId(), token, expiresAt);
|
||||
tokenMapper.insert(resetToken);
|
||||
/**
|
||||
* 重置密码
|
||||
* @param request 重置密码请求
|
||||
*/
|
||||
void resetPassword(ResetPasswordRequest request);
|
||||
|
||||
// 发送重置邮件
|
||||
emailService.sendPasswordResetEmail(user.getEmail(), user.getDisplayName(), token);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void resetPassword(ResetPasswordRequest request) {
|
||||
// 查找重置令牌
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.where(PasswordResetToken::getToken).eq(request.getToken())
|
||||
.and(PasswordResetToken::getUsed).eq(false);
|
||||
|
||||
PasswordResetToken resetToken = tokenMapper.selectOneByQuery(queryWrapper);
|
||||
|
||||
if (resetToken == null || !resetToken.isValid()) {
|
||||
throw new RuntimeException("重置令牌无效或已过期");
|
||||
}
|
||||
|
||||
// 更新用户密码
|
||||
User user = userMapper.selectOneById(resetToken.getUserId());
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getNewPassword()));
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
userMapper.update(user);
|
||||
|
||||
// 标记令牌为已使用
|
||||
resetToken.setUsed(true);
|
||||
tokenMapper.update(resetToken);
|
||||
}
|
||||
/**
|
||||
* 更新当前用户的个人资料
|
||||
* @param request 个人资料更新请求
|
||||
* @param currentUserId 当前用户ID
|
||||
* @return 更新后的用户DTO
|
||||
*/
|
||||
UserDTO updateCurrentUserProfile(UserProfileUpdateRequest request, Long currentUserId);
|
||||
|
||||
/**
|
||||
* 根据联系方式(邮箱或手机号)查找用户
|
||||
* @param contact 联系方式
|
||||
* @return 用户信息
|
||||
*/
|
||||
public User findByContact(String contact) {
|
||||
if (contact.contains("@")) {
|
||||
return findByEmail(contact);
|
||||
} else {
|
||||
return findByPhone(contact);
|
||||
}
|
||||
}
|
||||
|
||||
public User findByEmail(String email) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.where(User::getEmail).eq(email);
|
||||
return userMapper.selectOneByQuery(queryWrapper);
|
||||
}
|
||||
|
||||
public User findByPhone(String phone) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.where(User::getPhone).eq(phone);
|
||||
return userMapper.selectOneByQuery(queryWrapper);
|
||||
}
|
||||
|
||||
public boolean existsByEmail(String email) {
|
||||
return findByEmail(email) != null;
|
||||
}
|
||||
|
||||
public boolean existsByPhone(String phone) {
|
||||
return findByPhone(phone) != null;
|
||||
}
|
||||
User findByContact(String contact);
|
||||
|
||||
/**
|
||||
* 生成随机验证码
|
||||
* 根据邮箱查找用户
|
||||
* @param email 邮箱
|
||||
* @return 用户信息
|
||||
*/
|
||||
private String generateRandomCode() {
|
||||
Random random = new Random();
|
||||
int code = 100000 + random.nextInt(900000); // 生成6位随机数
|
||||
return String.valueOf(code);
|
||||
}
|
||||
User findByEmail(String email);
|
||||
|
||||
/**
|
||||
* 验证码信息类
|
||||
* 根据手机号查找用户
|
||||
* @param phone 手机号
|
||||
* @return 用户信息
|
||||
*/
|
||||
private static class VerificationCodeInfo {
|
||||
private final String code;
|
||||
private final LocalDateTime timestamp;
|
||||
User findByPhone(String phone);
|
||||
|
||||
public VerificationCodeInfo(String code, LocalDateTime timestamp) {
|
||||
this.code = code;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 检查邮箱是否已存在
|
||||
* @param email 邮箱
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByEmail(String email);
|
||||
|
||||
/**
|
||||
* 检查手机号是否已存在
|
||||
* @param phone 手机号
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean existsByPhone(String phone);
|
||||
}
|
||||
414
src/main/java/com/yundage/chat/service/impl/ChatServiceImpl.java
Normal file
414
src/main/java/com/yundage/chat/service/impl/ChatServiceImpl.java
Normal file
@@ -0,0 +1,414 @@
|
||||
package com.yundage.chat.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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 com.yundage.chat.service.ChatService;
|
||||
import com.yundage.chat.service.LLMService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 聊天服务实现类
|
||||
*/
|
||||
@Service
|
||||
public class ChatServiceImpl implements ChatService {
|
||||
|
||||
private ConversationMapper conversationMapper;
|
||||
|
||||
private MessageMapper messageMapper;
|
||||
|
||||
private final LLMService llmService;
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
|
||||
@Autowired
|
||||
public ChatServiceImpl(@Qualifier("customLLM") LLMService llmService,
|
||||
ConversationMapper conversationMapper,
|
||||
MessageMapper messageMapper) {
|
||||
this.llmService = llmService;
|
||||
this.conversationMapper = conversationMapper;
|
||||
this.messageMapper = messageMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@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();
|
||||
|
||||
// 准备额外参数
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
// 可以根据需要添加特定参数
|
||||
parameters.put("debug", false);
|
||||
parameters.put("show_thinking", false);
|
||||
parameters.put("agent_type","graph_agent");
|
||||
|
||||
// 3️⃣ 使用封装的LLM服务接口进行流式生成
|
||||
llmService.streamGenerateText(
|
||||
request.getMessage(),
|
||||
parameters,
|
||||
// 处理每个文本块
|
||||
chunk -> {
|
||||
try {
|
||||
String actualContent = extractActualContent(chunk);
|
||||
if (actualContent != null) {
|
||||
full.append(actualContent);
|
||||
}
|
||||
StreamResponse resp = StreamResponse.content(chunk);
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("message")
|
||||
.data(mapper.writeValueAsString(resp)));
|
||||
} catch (Exception io) {
|
||||
emitter.completeWithError(io);
|
||||
}
|
||||
},
|
||||
// 处理完成事件
|
||||
() -> {
|
||||
try {
|
||||
// 清理最终的内容,移除任何残留的JSON或状态标记
|
||||
String finalContent = cleanupFinalContent(full.toString());
|
||||
|
||||
Message assistantMsg = saveAssistantMessage(convo.getId(), finalContent);
|
||||
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(), finalContent),
|
||||
System.currentTimeMillis() - start
|
||||
);
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("finished")
|
||||
.data(mapper.writeValueAsString(finish)));
|
||||
emitter.complete();
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
},
|
||||
// 处理错误事件
|
||||
err -> {
|
||||
try {
|
||||
StreamResponse error = StreamResponse.error("AI 处理失败:" + err.getMessage());
|
||||
emitter.send(SseEmitter.event()
|
||||
.name("error")
|
||||
.data(mapper.writeValueAsString(error)));
|
||||
} catch (Exception ignore) {}
|
||||
emitter.completeWithError(err);
|
||||
}
|
||||
);
|
||||
}, ctx));
|
||||
} catch (Exception ex) {
|
||||
emitter.completeWithError(ex);
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从嵌套的JSON中提取实际内容
|
||||
* @param jsonStr JSON字符串
|
||||
* @return 提取的实际内容,或者在无法解析时返回原字符串
|
||||
*/
|
||||
private String extractActualContent(String jsonStr) {
|
||||
try {
|
||||
// 尝试解析外层JSON
|
||||
JsonNode node = mapper.readTree(jsonStr);
|
||||
|
||||
// 检查是否有状态字段,以及是否为token
|
||||
if (node.has("status") && "token".equals(node.get("status").asText()) && node.has("content")) {
|
||||
// 解析内层content字段的JSON
|
||||
String contentText = node.get("content").asText();
|
||||
if (contentText == null || contentText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode contentNode = mapper.readTree(contentText);
|
||||
|
||||
// 从内层JSON中获取实际内容
|
||||
if (contentNode.has("content")) {
|
||||
return contentNode.get("content").asText();
|
||||
} else if (contentNode.has("status") &&
|
||||
("done".equals(contentNode.get("status").asText()) ||
|
||||
"start".equals(contentNode.get("status").asText()))) {
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 内层JSON解析失败,可能不是JSON格式
|
||||
return contentText;
|
||||
}
|
||||
} else if (node.has("status") &&
|
||||
("done".equals(node.get("status").asText()) ||
|
||||
"start".equals(node.get("status").asText()))) {
|
||||
// 处理开始或结束信号,不需要添加内容
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果无法按预期解析,返回null避免添加不必要的内容
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
// 解析错误时返回null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理最终内容,移除任何残留的JSON或状态标记
|
||||
* @param content 原始内容
|
||||
* @return 清理后的内容
|
||||
*/
|
||||
private String cleanupFinalContent(String content) {
|
||||
if (content == null || content.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 移除任何结尾的JSON格式内容
|
||||
int jsonStart = content.lastIndexOf("{\"status\":");
|
||||
if (jsonStart >= 0) {
|
||||
content = content.substring(0, jsonStart);
|
||||
}
|
||||
|
||||
// 移除"**正在分析问题**..."等提示文本
|
||||
content = content.replaceAll("\\*\\*正在分析问题\\*\\*\\.\\.\\.", "")
|
||||
.replaceAll("\\*\\*正在检索相关信息\\*\\*\\.\\.\\.", "")
|
||||
.replaceAll("\\*\\*正在生成回答\\*\\*\\.\\.\\.", "");
|
||||
|
||||
// 清理多余的空行
|
||||
content = content.replaceAll("\\n\\s*\\n\\s*\\n", "\n\n")
|
||||
.trim();
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ChatResponse processChat(ChatRequest request, User user) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 获取或创建会话
|
||||
Conversation conversation = getOrCreateConversation(request, user);
|
||||
|
||||
// 保存用户消息
|
||||
Message userMessage = saveUserMessage(conversation.getId(), request.getMessage());
|
||||
|
||||
// 准备额外参数
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
if ("research".equals(request.getMode())) {
|
||||
parameters.put("systemPrompt", "你是一个专注于研究和深度分析的AI助手。提供详细、准确和有参考价值的信息。");
|
||||
parameters.put("temperature", 0.3); // 更低的温度,提高回答的确定性
|
||||
} else {
|
||||
parameters.put("systemPrompt", "你是一个友好、有帮助的AI助手。");
|
||||
parameters.put("temperature", 0.7); // 默认温度
|
||||
}
|
||||
|
||||
// 使用封装的LLM服务生成回复
|
||||
String aiResponse = llmService.generateText(request.getMessage(), parameters);
|
||||
|
||||
// 保存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) {
|
||||
// 准备额外参数
|
||||
Map<String, Object> parameters = new HashMap<>();
|
||||
if ("research".equals(request.getMode())) {
|
||||
parameters.put("systemPrompt", "你是一个专注于研究和深度分析的AI助手。提供详细、准确和有参考价值的信息。");
|
||||
parameters.put("temperature", 0.3);
|
||||
} else {
|
||||
parameters.put("systemPrompt", "你是一个友好、有帮助的AI助手。");
|
||||
parameters.put("temperature", 0.7);
|
||||
}
|
||||
|
||||
// 使用封装的LLM服务生成回复
|
||||
return llmService.generateText(request.getMessage(), parameters);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Conversation> getUserConversations(Long userId) {
|
||||
return conversationMapper.findActiveByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Message> getConversationMessages(Long conversationId, Long userId) {
|
||||
// 验证会话是否属于用户
|
||||
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, userId);
|
||||
if (conversation == null) {
|
||||
throw new RuntimeException("会话不存在或无权限访问");
|
||||
}
|
||||
|
||||
return messageMapper.findByConversationId(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户会话(标记为非活跃)
|
||||
* @param conversationId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @return 删除成功返回true,失败返回false
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public boolean deleteConversation(Long conversationId, Long userId) {
|
||||
// 验证会话是否属于用户
|
||||
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, userId);
|
||||
if (conversation == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 标记会话为非活跃
|
||||
conversation.setIsActive(0);
|
||||
conversation.setUpdatedAt(LocalDateTime.now());
|
||||
return conversationMapper.update(conversation) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.yundage.chat.service.impl;
|
||||
|
||||
import com.yundage.chat.service.LLMService;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 自定义协议的LLM微服务调用实现
|
||||
*/
|
||||
@Service
|
||||
@Qualifier("customLLM")
|
||||
public class CustomAPIModelService implements LLMService {
|
||||
|
||||
private final WebClient llmClient;
|
||||
|
||||
public CustomAPIModelService(@Qualifier("llmWebClient") WebClient llmClient) {
|
||||
this.llmClient = llmClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 阻塞式调用,返回完整文本
|
||||
*/
|
||||
@Override
|
||||
public String generateText(String userMessage, Map<String, Object> params) {
|
||||
// 构建请求体
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("message", userMessage);
|
||||
body.put("debug", false);
|
||||
body.put("show_thinking", false);
|
||||
body.put("agent_type", "graph_agent");
|
||||
|
||||
// 打印调试信息
|
||||
System.out.println("发送请求体: " + body);
|
||||
|
||||
return llmClient.post()
|
||||
.uri("/chat")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(BodyInserters.fromValue(body)) // 使用BodyInserters确保正确发送
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式调用,通过回调函数处理响应片段
|
||||
*/
|
||||
@Override
|
||||
public void streamGenerateText(String userMessage,
|
||||
Map<String, Object> params,
|
||||
Consumer<String> onChunk,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
// --------- 1. 组装 JSON ---------- //
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("message", userMessage);
|
||||
body.put("session_id", params.getOrDefault("session_id",
|
||||
UUID.randomUUID().toString())); // 必要字段
|
||||
body.put("debug", false);
|
||||
body.put("show_thinking", false);
|
||||
body.put("agent_type", "graph_agent");
|
||||
|
||||
System.out.println("[SSE‑REQ] " + body); // 调试输出
|
||||
|
||||
// --------- 2. 发起 SSE 请求 ---------- //
|
||||
llmClient.post()
|
||||
.uri("/chat/stream")
|
||||
.contentType(MediaType.APPLICATION_JSON) // 告诉后端:我发 JSON
|
||||
.accept(MediaType.TEXT_EVENT_STREAM) // 我要拿到 SSE
|
||||
.bodyValue(body) // **一定** 用 bodyValue
|
||||
.retrieve()
|
||||
.bodyToFlux(String.class) // 每行都是一条 event data
|
||||
.doOnNext(chunk -> {
|
||||
System.out.println("[SSE‑RESP] " + chunk);
|
||||
onChunk.accept(chunk);
|
||||
})
|
||||
.doOnComplete(onComplete)
|
||||
.doOnError(e -> {
|
||||
System.err.println("[SSE‑ERR] " + e.getMessage());
|
||||
onError.accept(e);
|
||||
})
|
||||
.subscribe(); // 订阅并开始请求
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话ID,如果参数中有则使用参数中的,否则生成一个新的
|
||||
*/
|
||||
private String getSessionId(Map<String, Object> params) {
|
||||
if (params != null && params.containsKey("sessionId")) {
|
||||
return params.get("sessionId").toString();
|
||||
}
|
||||
return "debug-session-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yundage.chat.service.impl;
|
||||
|
||||
import com.yundage.chat.service.EmailService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 邮件服务实现类
|
||||
*/
|
||||
@Service
|
||||
public class EmailServiceImpl implements EmailService {
|
||||
|
||||
@Autowired
|
||||
private JavaMailSender mailSender;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
@Value("${app.reset-password-url:http://localhost:3000/reset-password}")
|
||||
private String resetPasswordUrl;
|
||||
|
||||
@Override
|
||||
public void sendPasswordResetEmail(String toEmail, String username, String token) {
|
||||
try {
|
||||
SimpleMailMessage message = new SimpleMailMessage();
|
||||
message.setFrom(fromEmail);
|
||||
message.setTo(toEmail);
|
||||
message.setSubject("密码重置 - 云大AI聊天");
|
||||
|
||||
String resetLink = resetPasswordUrl + "?token=" + token;
|
||||
String emailBody = String.format(
|
||||
"亲爱的 %s,\n\n" +
|
||||
"我们收到了您的密码重置请求。请点击以下链接重置您的密码:\n\n" +
|
||||
"%s\n\n" +
|
||||
"此链接将在1小时后过期。如果您没有请求重置密码,请忽略此邮件。\n\n" +
|
||||
"祝好,\n" +
|
||||
"云大AI聊天团队",
|
||||
username, resetLink
|
||||
);
|
||||
|
||||
message.setText(emailBody);
|
||||
mailSender.send(message);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("发送邮件失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.yundage.chat.service.impl;
|
||||
|
||||
import com.yundage.chat.service.LLMService;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* 基于 Spring AI 1.0.0‑M6 的 LLM 服务
|
||||
*/
|
||||
@Service
|
||||
public class SpringAIModelService implements LLMService {
|
||||
|
||||
private final ChatClient chatClient;
|
||||
|
||||
public SpringAIModelService(ChatClient.Builder builder) {
|
||||
// Spring Boot 已为 Builder 注入模型、API‑Key 等配置
|
||||
this.chatClient = builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void streamGenerateText(String userMessage,
|
||||
Map<String, Object> parameters,
|
||||
Consumer<String> callback,
|
||||
Runnable onComplete,
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
// 1. 起一个 fluent builder
|
||||
var prompt = chatClient.prompt();
|
||||
|
||||
// 2. 可选:加入 system prompt
|
||||
if (parameters != null && parameters.containsKey("systemPrompt")) {
|
||||
prompt.system((String) parameters.get("systemPrompt"));
|
||||
}
|
||||
|
||||
// 3. 加入用户消息并开启流式
|
||||
Flux<String> flux = prompt.user(userMessage) // 添加 user role
|
||||
.stream() // 切换为 Flux
|
||||
.content(); // 只取文本
|
||||
|
||||
// 4. 订阅
|
||||
flux.doOnNext(callback)
|
||||
.doOnComplete(onComplete)
|
||||
.doOnError(onError)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateText(String userMessage, Map<String, Object> parameters) {
|
||||
|
||||
var prompt = chatClient.prompt();
|
||||
|
||||
if (parameters != null && parameters.containsKey("systemPrompt")) {
|
||||
prompt.system((String) parameters.get("systemPrompt"));
|
||||
}
|
||||
|
||||
// 同步调用,直接返回完整结果
|
||||
return prompt.user(userMessage)
|
||||
.call() // 阻塞请求
|
||||
.content(); // 取文本
|
||||
}
|
||||
}
|
||||
364
src/main/java/com/yundage/chat/service/impl/UserServiceImpl.java
Normal file
364
src/main/java/com/yundage/chat/service/impl/UserServiceImpl.java
Normal file
@@ -0,0 +1,364 @@
|
||||
package com.yundage.chat.service.impl;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.yundage.chat.dto.*;
|
||||
import com.yundage.chat.entity.PasswordResetToken;
|
||||
import com.yundage.chat.entity.User;
|
||||
import com.yundage.chat.mapper.PasswordResetTokenMapper;
|
||||
import com.yundage.chat.mapper.UserMapper;
|
||||
import com.yundage.chat.service.EmailService;
|
||||
import com.yundage.chat.service.UserService;
|
||||
import com.yundage.chat.util.JwtUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 用户服务实现类
|
||||
*/
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Autowired
|
||||
private PasswordResetTokenMapper tokenMapper;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Autowired
|
||||
private AuthenticationManager authenticationManager;
|
||||
|
||||
@Autowired
|
||||
private EmailService emailService;
|
||||
|
||||
// 从配置文件中获取验证码配置
|
||||
@Value("${app.verification-code.master-code:123456}")
|
||||
private String masterCode;
|
||||
|
||||
@Value("${app.verification-code.expiration:300}")
|
||||
private int codeExpirationSeconds;
|
||||
|
||||
@Value("${app.verification-code.development-mode:false}")
|
||||
private boolean developmentMode;
|
||||
|
||||
// 验证码存储,实际项目中应该使用Redis
|
||||
private final Map<String, VerificationCodeInfo> verificationCodes = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public String sendVerificationCode(String contact) {
|
||||
// 生成6位随机验证码
|
||||
String code = generateRandomCode();
|
||||
|
||||
// 存储验证码
|
||||
verificationCodes.put(contact, new VerificationCodeInfo(code, LocalDateTime.now()));
|
||||
|
||||
// TODO: 实际项目中,这里应该通过短信或邮件发送验证码
|
||||
// 如果是邮箱,可以使用邮件服务发送
|
||||
if (contact.contains("@")) {
|
||||
// emailService.sendVerificationCode(contact, code);
|
||||
} else {
|
||||
// 使用短信服务发送
|
||||
// smsService.sendVerificationCode(contact, code);
|
||||
}
|
||||
|
||||
// 在开发模式下,在控制台打印验证码
|
||||
if (developmentMode) {
|
||||
System.out.println("向 " + contact + " 发送验证码: " + code);
|
||||
}
|
||||
|
||||
return developmentMode ? code : null; // 仅在开发模式下返回验证码
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* @param contact 联系方式
|
||||
* @param code 验证码
|
||||
* @return 是否验证成功
|
||||
*/
|
||||
private boolean verifyCode(String contact, String code) {
|
||||
// 检查万能验证码
|
||||
if (masterCode.equals(code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查验证码是否存在
|
||||
VerificationCodeInfo storedInfo = verificationCodes.get(contact);
|
||||
if (storedInfo == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查验证码是否过期
|
||||
LocalDateTime expirationTime = storedInfo.timestamp.plusSeconds(codeExpirationSeconds);
|
||||
if (LocalDateTime.now().isAfter(expirationTime)) {
|
||||
verificationCodes.remove(contact); // 移除过期验证码
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查验证码是否匹配
|
||||
boolean isValid = storedInfo.code.equals(code);
|
||||
if (isValid) {
|
||||
verificationCodes.remove(contact); // 验证成功后移除验证码,防止重复使用
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
// 验证验证码
|
||||
if (!verifyCode(request.getEmail() != null ? request.getEmail() : request.getPhone(), request.getCode())) {
|
||||
throw new RuntimeException("验证码无效或已过期");
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
if (request.getEmail() != null && existsByEmail(request.getEmail())) {
|
||||
throw new RuntimeException("邮箱已被注册");
|
||||
}
|
||||
|
||||
if (request.getPhone() != null && existsByPhone(request.getPhone())) {
|
||||
throw new RuntimeException("手机号已被注册");
|
||||
}
|
||||
|
||||
// 创建新用户
|
||||
User user = new User();
|
||||
user.setUsername(request.getUsername());
|
||||
user.setEmail(request.getEmail());
|
||||
user.setPhone(request.getPhone());
|
||||
// 设置一个随机密码,因为不再使用密码登录
|
||||
user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
|
||||
userMapper.insert(user);
|
||||
|
||||
// 生成JWT令牌
|
||||
String token = jwtUtil.generateToken(user.getDisplayName(), user.getId());
|
||||
|
||||
return new AuthResponse(token, user.getId(), user.getDisplayName(),
|
||||
user.getEmail(), user.getPhone());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
// 验证验证码
|
||||
if (!verifyCode(request.getContact(), request.getCode())) {
|
||||
throw new RuntimeException("验证码无效或已过期");
|
||||
}
|
||||
|
||||
// 根据联系方式查找用户
|
||||
User user = findByContact(request.getContact());
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 手动设置认证信息
|
||||
Authentication authentication = createAuthenticationToken(user);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
// 更新最后登录时间
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
userMapper.update(user);
|
||||
|
||||
// 生成JWT令牌
|
||||
String token = jwtUtil.generateToken(user.getDisplayName(), user.getId());
|
||||
|
||||
return new AuthResponse(token, user.getId(), user.getDisplayName(),
|
||||
user.getEmail(), user.getPhone());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void logout() {
|
||||
// 清除认证上下文
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
// 注意:由于JWT是无状态的,服务端无法使单个令牌失效
|
||||
// 在实际生产环境中,可以采用以下策略之一:
|
||||
// 1. 将令牌添加到黑名单(通常使用Redis实现)
|
||||
// 2. 使用较短的令牌过期时间
|
||||
// 3. 维护一个令牌版本号,当用户登出时更新版本号,使旧令牌无效
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建认证令牌
|
||||
*/
|
||||
private Authentication createAuthenticationToken(User user) {
|
||||
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
|
||||
return new UsernamePasswordAuthenticationToken(user, null, authorities);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void requestPasswordReset(PasswordResetRequest request) {
|
||||
User user = findByEmail(request.getEmail());
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 生成重置令牌
|
||||
String token = UUID.randomUUID().toString();
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusHours(1); // 1小时后过期
|
||||
|
||||
PasswordResetToken resetToken = new PasswordResetToken(user.getId(), token, expiresAt);
|
||||
tokenMapper.insert(resetToken);
|
||||
|
||||
// 发送重置邮件
|
||||
emailService.sendPasswordResetEmail(user.getEmail(), user.getDisplayName(), token);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void resetPassword(ResetPasswordRequest request) {
|
||||
// 查找重置令牌
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.where(PasswordResetToken::getToken).eq(request.getToken())
|
||||
.and(PasswordResetToken::getUsed).eq(false);
|
||||
|
||||
PasswordResetToken resetToken = tokenMapper.selectOneByQuery(queryWrapper);
|
||||
|
||||
if (resetToken == null || !resetToken.isValid()) {
|
||||
throw new RuntimeException("重置令牌无效或已过期");
|
||||
}
|
||||
|
||||
// 更新用户密码
|
||||
User user = userMapper.selectOneById(resetToken.getUserId());
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.getNewPassword()));
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
userMapper.update(user);
|
||||
|
||||
// 标记令牌为已使用
|
||||
resetToken.setUsed(true);
|
||||
tokenMapper.update(resetToken);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UserDTO updateCurrentUserProfile(UserProfileUpdateRequest request, Long currentUserId) {
|
||||
// 获取当前用户
|
||||
User user = userMapper.selectOneById(currentUserId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 验证邮箱和手机号是否被其他用户占用
|
||||
if (request.getEmail() != null && !request.getEmail().equals(user.getEmail()) && existsByEmail(request.getEmail())) {
|
||||
throw new RuntimeException("邮箱已被其他用户使用");
|
||||
}
|
||||
|
||||
if (request.getPhone() != null && !request.getPhone().equals(user.getPhone()) && existsByPhone(request.getPhone())) {
|
||||
throw new RuntimeException("手机号已被其他用户使用");
|
||||
}
|
||||
|
||||
// 更新用户资料
|
||||
boolean hasUpdates = false;
|
||||
|
||||
if (request.getUsername() != null && !request.getUsername().equals(user.getUsername())) {
|
||||
user.setUsername(request.getUsername());
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
if (request.getAvatarUrl() != null && !request.getAvatarUrl().equals(user.getAvatarUrl())) {
|
||||
user.setAvatarUrl(request.getAvatarUrl());
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
if (request.getEmail() != null && !request.getEmail().equals(user.getEmail())) {
|
||||
user.setEmail(request.getEmail());
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
if (request.getPhone() != null && !request.getPhone().equals(user.getPhone())) {
|
||||
user.setPhone(request.getPhone());
|
||||
hasUpdates = true;
|
||||
}
|
||||
|
||||
// 如果有更新,则保存到数据库
|
||||
if (hasUpdates) {
|
||||
user.setUpdatedAt(LocalDateTime.now());
|
||||
userMapper.update(user);
|
||||
}
|
||||
|
||||
// 转换为DTO并返回
|
||||
return UserDTO.fromUser(user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public User findByContact(String contact) {
|
||||
if (contact.contains("@")) {
|
||||
return findByEmail(contact);
|
||||
} else {
|
||||
return findByPhone(contact);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public User findByEmail(String email) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.where(User::getEmail).eq(email);
|
||||
return userMapper.selectOneByQuery(queryWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public User findByPhone(String phone) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create()
|
||||
.where(User::getPhone).eq(phone);
|
||||
return userMapper.selectOneByQuery(queryWrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByEmail(String email) {
|
||||
return findByEmail(email) != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean existsByPhone(String phone) {
|
||||
return findByPhone(phone) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机验证码
|
||||
*/
|
||||
private String generateRandomCode() {
|
||||
java.util.Random random = new java.util.Random();
|
||||
int code = 100000 + random.nextInt(900000); // 生成6位随机数
|
||||
return String.valueOf(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码信息类
|
||||
*/
|
||||
private static class VerificationCodeInfo {
|
||||
private final String code;
|
||||
private final LocalDateTime timestamp;
|
||||
|
||||
public VerificationCodeInfo(String code, LocalDateTime timestamp) {
|
||||
this.code = code;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ spring:
|
||||
model: gpt-4o-mini
|
||||
security:
|
||||
debug: true
|
||||
llm:
|
||||
base-url: http://127.0.0.1:8000 # 可以是服务名或完整URL
|
||||
# api-key: sk-xxxx # 预留认证字段
|
||||
# MyBatis-Flex configuration
|
||||
mybatis-flex:
|
||||
# Enable SQL logging
|
||||
@@ -64,8 +67,13 @@ springdoc:
|
||||
# Logging configuration
|
||||
logging:
|
||||
level:
|
||||
org.mybatis: warn
|
||||
org.apache.ibatis: warn
|
||||
org.apache.ibatis.logging: off
|
||||
org.mybatis.spring.SqlSessionUtils: warn
|
||||
com.yundage.chat: debug
|
||||
com.mybatisflex: info
|
||||
com.mybatisflex: warn
|
||||
org.springframework.security: DEBUG
|
||||
reactor.netty.http.client: DEBUG
|
||||
# org.springframework: DEBUG
|
||||
#debug: true
|
||||
@@ -1,113 +1,131 @@
|
||||
-- Create database
|
||||
CREATE DATABASE IF NOT EXISTS chat CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
/*
|
||||
Navicat Premium Data Transfer
|
||||
|
||||
USE chat;
|
||||
Source Server : 云大
|
||||
Source Server Type : MySQL
|
||||
Source Server Version : 80405 (8.4.5)
|
||||
Source Host : 101.200.154.78:3306
|
||||
Source Schema : yunda_qa
|
||||
|
||||
-- 1. 用户主表
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(50) DEFAULT NULL COMMENT '昵称/展示名',
|
||||
password_hash VARCHAR(255) DEFAULT NULL COMMENT '哈希密码(可为空)',
|
||||
phone VARCHAR(20) UNIQUE DEFAULT NULL COMMENT '手机号',
|
||||
email VARCHAR(100)UNIQUE DEFAULT NULL COMMENT '邮箱',
|
||||
avatar_url VARCHAR(255) DEFAULT NULL,
|
||||
user_type ENUM('personal','enterprise','admin')
|
||||
NOT NULL DEFAULT 'personal' COMMENT '用户类型',
|
||||
membership_level_id SMALLINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '付费等级',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常, 0=封禁',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
last_login_at DATETIME NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户主表';
|
||||
Target Server Type : MySQL
|
||||
Target Server Version : 80405 (8.4.5)
|
||||
File Encoding : 65001
|
||||
|
||||
-- 2. 第三方账号绑定表
|
||||
CREATE TABLE user_auth_accounts (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
provider ENUM('wechat_mini','wechat_open','google','github','apple','custom')
|
||||
NOT NULL COMMENT '登录方式',
|
||||
provider_user_id VARCHAR(100) NOT NULL COMMENT '第三方平台唯一ID',
|
||||
access_token VARCHAR(255) DEFAULT NULL,
|
||||
refresh_token VARCHAR(255) DEFAULT NULL,
|
||||
expires_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_provider_user (provider, provider_user_id),
|
||||
CONSTRAINT fk_auth_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方登录账号绑定表';
|
||||
Date: 21/07/2025 09:29:04
|
||||
*/
|
||||
|
||||
-- 3. 会员等级表
|
||||
CREATE TABLE membership_levels (
|
||||
id SMALLINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
price_month DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '月费',
|
||||
daily_msg_limit INT UNSIGNED NOT NULL DEFAULT 20,
|
||||
features JSON DEFAULT NULL COMMENT '权限 JSON',
|
||||
sort_order TINYINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员等级定义';
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- 4. 会话表(支持普通与研究模式)
|
||||
CREATE TABLE conversations (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
title VARCHAR(100) DEFAULT NULL,
|
||||
model_version VARCHAR(50) DEFAULT NULL COMMENT '使用模型版本',
|
||||
chat_mode ENUM('chat','research')
|
||||
NOT NULL DEFAULT 'chat' COMMENT 'chat=普通,research=深度研究',
|
||||
is_active TINYINT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_conv_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话表';
|
||||
-- ----------------------------
|
||||
-- Table structure for conversation_research_meta
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `conversation_research_meta`;
|
||||
CREATE TABLE `conversation_research_meta` (
|
||||
`conversation_id` bigint UNSIGNED NOT NULL,
|
||||
`topic` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '研究主题',
|
||||
`goal` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '研究目标描述',
|
||||
`progress_json` json NULL COMMENT '阶段进度',
|
||||
`draft_report_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT '草稿报告 ID(预留)',
|
||||
`cite_style` enum('APA','IEEE','GB/T-7714') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'APA',
|
||||
`tokens_consumed` int UNSIGNED NOT NULL DEFAULT 0,
|
||||
`last_summary_at` datetime NULL DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`conversation_id`) USING BTREE,
|
||||
CONSTRAINT `fk_research_conv` FOREIGN KEY (`conversation_id`) REFERENCES `conversations` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '深度研究元数据' ROW_FORMAT = Dynamic;
|
||||
|
||||
-- 5. 深度研究扩展表
|
||||
CREATE TABLE conversation_research_meta (
|
||||
conversation_id BIGINT UNSIGNED PRIMARY KEY,
|
||||
topic VARCHAR(200) NOT NULL COMMENT '研究主题',
|
||||
goal TEXT DEFAULT NULL COMMENT '研究目标描述',
|
||||
progress_json JSON DEFAULT NULL COMMENT '阶段进度',
|
||||
draft_report_id BIGINT UNSIGNED DEFAULT NULL COMMENT '草稿报告 ID(预留)',
|
||||
cite_style ENUM('APA','IEEE','GB/T-7714') DEFAULT 'APA',
|
||||
tokens_consumed INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_summary_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_research_conv FOREIGN KEY (conversation_id)
|
||||
REFERENCES conversations(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='深度研究元数据';
|
||||
-- ----------------------------
|
||||
-- Table structure for conversations
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `conversations`;
|
||||
CREATE TABLE `conversations` (
|
||||
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint UNSIGNED NOT NULL,
|
||||
`title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`model_version` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '使用模型版本',
|
||||
`chat_mode` enum('chat','research') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'chat' COMMENT 'chat=普通,research=深度研究',
|
||||
`is_active` tinyint NOT NULL DEFAULT 1,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `fk_conv_user`(`user_id` ASC) USING BTREE,
|
||||
CONSTRAINT `fk_conv_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 89 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '会话表' ROW_FORMAT = Dynamic;
|
||||
|
||||
-- 6. 消息表
|
||||
CREATE TABLE messages (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
conversation_id BIGINT UNSIGNED NOT NULL,
|
||||
sequence_no INT UNSIGNED NOT NULL COMMENT '会话内顺序编号',
|
||||
role ENUM('user','assistant','system','tool')
|
||||
NOT NULL,
|
||||
content LONGTEXT NOT NULL,
|
||||
tokens INT UNSIGNED DEFAULT NULL,
|
||||
latency_ms INT UNSIGNED DEFAULT NULL COMMENT '响应耗时',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_msg_conv FOREIGN KEY (conversation_id) REFERENCES conversations(id)
|
||||
ON DELETE CASCADE,
|
||||
UNIQUE KEY uq_conv_seq (conversation_id, sequence_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息表';
|
||||
-- ----------------------------
|
||||
-- Table structure for membership_levels
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `membership_levels`;
|
||||
CREATE TABLE `membership_levels` (
|
||||
`id` smallint UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
|
||||
`price_month` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '月费',
|
||||
`daily_msg_limit` int UNSIGNED NOT NULL DEFAULT 20,
|
||||
`features` json NULL COMMENT '权限 JSON',
|
||||
`sort_order` tinyint NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '会员等级定义' ROW_FORMAT = Dynamic;
|
||||
|
||||
-- 7. 密码重置令牌表(用于密码重置功能)
|
||||
CREATE TABLE password_reset_tokens (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used TINYINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_reset_user FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='密码重置令牌表';
|
||||
-- ----------------------------
|
||||
-- Table structure for messages
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `messages`;
|
||||
CREATE TABLE `messages` (
|
||||
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`conversation_id` bigint UNSIGNED NOT NULL,
|
||||
`sequence_no` int UNSIGNED NOT NULL COMMENT '会话内顺序编号',
|
||||
`role` enum('user','assistant','system','tool') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
|
||||
`content` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
|
||||
`tokens` int UNSIGNED NULL DEFAULT NULL,
|
||||
`latency_ms` int UNSIGNED NULL DEFAULT NULL COMMENT '响应耗时',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE INDEX `uq_conv_seq`(`conversation_id` ASC, `sequence_no` ASC) USING BTREE,
|
||||
CONSTRAINT `fk_msg_conv` FOREIGN KEY (`conversation_id`) REFERENCES `conversations` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 241 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '消息表' ROW_FORMAT = Dynamic;
|
||||
|
||||
-- 插入默认会员等级数据
|
||||
INSERT INTO membership_levels (id, name, price_month, daily_msg_limit, features, sort_order) VALUES
|
||||
(1, '免费版', 0.00, 20, '{"models": ["gpt-3.5"], "features": ["basic_chat"]}', 1),
|
||||
(2, '专业版', 29.99, 100, '{"models": ["gpt-3.5", "gpt-4"], "features": ["basic_chat", "research_mode"]}', 2),
|
||||
(3, '企业版', 99.99, 1000, '{"models": ["gpt-3.5", "gpt-4", "gpt-4-turbo"], "features": ["basic_chat", "research_mode", "priority_support"]}', 3);
|
||||
-- ----------------------------
|
||||
-- Table structure for user_auth_accounts
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `user_auth_accounts`;
|
||||
CREATE TABLE `user_auth_accounts` (
|
||||
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint UNSIGNED NOT NULL,
|
||||
`provider` enum('wechat_mini','wechat_open','google','github','apple','custom') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录方式',
|
||||
`provider_user_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '第三方平台唯一ID',
|
||||
`access_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`refresh_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`expires_at` datetime NULL DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE INDEX `uq_provider_user`(`provider` ASC, `provider_user_id` ASC) USING BTREE,
|
||||
INDEX `fk_auth_user`(`user_id` ASC) USING BTREE,
|
||||
CONSTRAINT `fk_auth_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '第三方登录账号绑定表' ROW_FORMAT = Dynamic;
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for users
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
CREATE TABLE `users` (
|
||||
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '昵称/展示名',
|
||||
`password_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '哈希密码(可为空)',
|
||||
`phone` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号',
|
||||
`email` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
|
||||
`avatar_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
|
||||
`user_type` enum('personal','enterprise','admin') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'personal' COMMENT '用户类型',
|
||||
`membership_level_id` smallint UNSIGNED NOT NULL DEFAULT 1 COMMENT '付费等级',
|
||||
`status` tinyint NOT NULL DEFAULT 1 COMMENT '1=正常, 0=封禁',
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`last_login_at` datetime NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE INDEX `phone`(`phone` ASC) USING BTREE,
|
||||
UNIQUE INDEX `email`(`email` ASC) USING BTREE
|
||||
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户主表' ROW_FORMAT = Dynamic;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
Reference in New Issue
Block a user