diff --git a/docs/忘记密码和重置密码.md b/docs/忘记密码和重置密码.md new file mode 100644 index 0000000..088b3d6 --- /dev/null +++ b/docs/忘记密码和重置密码.md @@ -0,0 +1,23 @@ +"忘记密码"(Forgot Password)和"重置密码"(Reset Password)是密码恢复流程的两个不同步骤: + +1. 忘记密码(forgotPassword): + +- 流程的第一步 + +- 当用户表示忘记密码时触发 + +- 系统向用户发送带有重置令牌的邮件 + +- 不直接更改密码 + +1. 重置密码(resetPassword): + +- 流程的第二步 + +- 用户点击邮件中的链接或输入令牌后使用 + +- 验证令牌并允许用户设置新密码 + +- 实际修改用户密码的步骤 + +简单来说,忘记密码是发起恢复流程,重置密码是完成密码更改。 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 8065556..87a3aa3 100644 --- a/pom.xml +++ b/pom.xml @@ -119,6 +119,13 @@ lombok provided + + + io.projectreactor.netty + reactor-netty-http + 1.1.15 + + @@ -127,6 +134,13 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-compiler-plugin + + true + + \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/ChatApplication.java b/src/main/java/com/yundage/chat/ChatApplication.java index 984166b..74f4b59 100644 --- a/src/main/java/com/yundage/chat/ChatApplication.java +++ b/src/main/java/com/yundage/chat/ChatApplication.java @@ -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) { diff --git a/src/main/java/com/yundage/chat/common/ApiResponse.java b/src/main/java/com/yundage/chat/common/ApiResponse.java index 64cdbaa..41fbc22 100644 --- a/src/main/java/com/yundage/chat/common/ApiResponse.java +++ b/src/main/java/com/yundage/chat/common/ApiResponse.java @@ -64,6 +64,10 @@ public class ApiResponse { return new ApiResponse<>(ErrorCode.NOT_FOUND, message); } + public static ApiResponse conflict(String message) { + return new ApiResponse<>(ErrorCode.CONFLICT, message); + } + // Getters and Setters public int getCode() { return code; diff --git a/src/main/java/com/yundage/chat/common/ErrorCode.java b/src/main/java/com/yundage/chat/common/ErrorCode.java index 3d48440..3bcf618 100644 --- a/src/main/java/com/yundage/chat/common/ErrorCode.java +++ b/src/main/java/com/yundage/chat/common/ErrorCode.java @@ -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; diff --git a/src/main/java/com/yundage/chat/config/LlmApiProperties.java b/src/main/java/com/yundage/chat/config/LlmApiProperties.java new file mode 100644 index 0000000..25df20f --- /dev/null +++ b/src/main/java/com/yundage/chat/config/LlmApiProperties.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/config/LlmWebClientConfig.java b/src/main/java/com/yundage/chat/config/LlmWebClientConfig.java new file mode 100644 index 0000000..6d1e130 --- /dev/null +++ b/src/main/java/com/yundage/chat/config/LlmWebClientConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/yundage/chat/config/OpenApiConfig.java b/src/main/java/com/yundage/chat/config/OpenApiConfig.java index 39853b3..927f768 100644 --- a/src/main/java/com/yundage/chat/config/OpenApiConfig.java +++ b/src/main/java/com/yundage/chat/config/OpenApiConfig.java @@ -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") diff --git a/src/main/java/com/yundage/chat/controller/AuthController.java b/src/main/java/com/yundage/chat/controller/AuthController.java index 4da0fb6..a31fd48 100644 --- a/src/main/java/com/yundage/chat/controller/AuthController.java +++ b/src/main/java/com/yundage/chat/controller/AuthController.java @@ -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 = { diff --git a/src/main/java/com/yundage/chat/controller/ChatController.java b/src/main/java/com/yundage/chat/controller/ChatController.java index 36d7713..4da8bd3 100644 --- a/src/main/java/com/yundage/chat/controller/ChatController.java +++ b/src/main/java/com/yundage/chat/controller/ChatController.java @@ -87,4 +87,24 @@ public class ChatController { return ResponseEntity.ok(ApiResponse.error("获取消息历史失败:" + e.getMessage())); } } + + @DeleteMapping("/conversations/{conversationId}") + @Operation(summary = "删除会话", description = "删除指定ID的会话") + public ResponseEntity> 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())); + } + } } \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/controller/UserController.java b/src/main/java/com/yundage/chat/controller/UserController.java index bce748e..44b2299 100644 --- a/src/main/java/com/yundage/chat/controller/UserController.java +++ b/src/main/java/com/yundage/chat/controller/UserController.java @@ -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 getAllUsers() { - return userMapper.selectAll(); + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "成功获取用户列表") + public ApiResponse> 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 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 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 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 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 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 deleteUser(@Parameter(description = "用户ID") @PathVariable Long id) { + User existingUser = userMapper.selectOneById(id); + if (existingUser == null) { + return ApiResponse.notFound("用户不存在"); + } userMapper.deleteById(id); + return ApiResponse.success(); } } \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/dto/UserDTO.java b/src/main/java/com/yundage/chat/dto/UserDTO.java new file mode 100644 index 0000000..7bc20e2 --- /dev/null +++ b/src/main/java/com/yundage/chat/dto/UserDTO.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/dto/UserProfileUpdateRequest.java b/src/main/java/com/yundage/chat/dto/UserProfileUpdateRequest.java new file mode 100644 index 0000000..3f2cf2d --- /dev/null +++ b/src/main/java/com/yundage/chat/dto/UserProfileUpdateRequest.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/mapper/UserMapper.java b/src/main/java/com/yundage/chat/mapper/UserMapper.java index 5df3a0f..3e0cfb6 100644 --- a/src/main/java/com/yundage/chat/mapper/UserMapper.java +++ b/src/main/java/com/yundage/chat/mapper/UserMapper.java @@ -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 { + + default User selectByEmailOrPhone(String username) { + QueryWrapper queryWrapper = new QueryWrapper(); + queryWrapper.where("email = ? OR phone = ?", username, username).limit(1); + return selectOneByQuery(queryWrapper); + } } \ 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 index 22f7f5d..8c189cb 100644 --- a/src/main/java/com/yundage/chat/service/ChatService.java +++ b/src/main/java/com/yundage/chat/service/ChatService.java @@ -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; - - - 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); - } +/** + * 聊天服务接口 + */ +public interface ChatService { + + /** + * 处理流式聊天请求 + * @param request 聊天请求 + * @param user 当前用户 + * @return SSE发射器 + */ + SseEmitter processChatStream(ChatRequest request, User user); + + /** + * 处理普通聊天请求 + * @param request 聊天请求 + * @param user 当前用户 + * @return 聊天响应 + */ + ChatResponse processChat(ChatRequest request, User user); + + /** + * 获取用户的会话列表 + * @param userId 用户ID + * @return 会话列表 + */ + List getUserConversations(Long userId); + + /** + * 获取会话消息历史 + * @param conversationId 会话ID + * @param userId 用户ID + * @return 消息列表 + */ + List getConversationMessages(Long conversationId, Long userId); + + /** + * 删除用户会话 + * @param conversationId 会话ID + * @param userId 用户ID + * @return 是否删除成功 + */ + boolean deleteConversation(Long conversationId, Long userId); } \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/EmailService.java b/src/main/java/com/yundage/chat/service/EmailService.java index 449a1a4..ddc3544 100644 --- a/src/main/java/com/yundage/chat/service/EmailService.java +++ b/src/main/java/com/yundage/chat/service/EmailService.java @@ -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; - -@Service -public class EmailService { +/** + * 邮件服务接口 + */ +public interface 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); } \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/LLMService.java b/src/main/java/com/yundage/chat/service/LLMService.java new file mode 100644 index 0000000..61a3081 --- /dev/null +++ b/src/main/java/com/yundage/chat/service/LLMService.java @@ -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 parameters, + Consumer callback, + Runnable onComplete, + Consumer onError); + + /** + * 处理非流式文本生成请求 + * @param userMessage 用户消息 + * @param parameters 额外的参数,如温度、最大长度等 + * @return 生成的文本 + */ + String generateText(String userMessage, Map parameters); +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/UserService.java b/src/main/java/com/yundage/chat/service/UserService.java index 8c05143..d8e02e3 100644 --- a/src/main/java/com/yundage/chat/service/UserService.java +++ b/src/main/java/com/yundage/chat/service/UserService.java @@ -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 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 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("用户不存在"); - } - - // 生成重置令牌 - 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); - } + /** + * 用户登出 + */ + void logout(); - @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 密码重置请求 + */ + void requestPasswordReset(PasswordResetRequest request); + + /** + * 重置密码 + * @param request 重置密码请求 + */ + void resetPassword(ResetPasswordRequest request); + + /** + * 更新当前用户的个人资料 + * @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; - - public VerificationCodeInfo(String code, LocalDateTime timestamp) { - this.code = code; - this.timestamp = timestamp; - } - } + User findByPhone(String phone); + + /** + * 检查邮箱是否已存在 + * @param email 邮箱 + * @return 是否存在 + */ + boolean existsByEmail(String email); + + /** + * 检查手机号是否已存在 + * @param phone 手机号 + * @return 是否存在 + */ + boolean existsByPhone(String phone); } \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/impl/ChatServiceImpl.java b/src/main/java/com/yundage/chat/service/impl/ChatServiceImpl.java new file mode 100644 index 0000000..e5f3802 --- /dev/null +++ b/src/main/java/com/yundage/chat/service/impl/ChatServiceImpl.java @@ -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 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 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 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 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 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; + } + + @Override + public List getUserConversations(Long userId) { + return conversationMapper.findActiveByUserId(userId); + } + + @Override + public List 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; + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/impl/CustomAPIModelService.java b/src/main/java/com/yundage/chat/service/impl/CustomAPIModelService.java new file mode 100644 index 0000000..bc56719 --- /dev/null +++ b/src/main/java/com/yundage/chat/service/impl/CustomAPIModelService.java @@ -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 params) { + // 构建请求体 + Map 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 params, + Consumer onChunk, + Runnable onComplete, + Consumer onError) { + + // --------- 1. 组装 JSON ---------- // + Map 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 params) { + if (params != null && params.containsKey("sessionId")) { + return params.get("sessionId").toString(); + } + return "debug-session-" + UUID.randomUUID().toString().substring(0, 8); + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/impl/EmailServiceImpl.java b/src/main/java/com/yundage/chat/service/impl/EmailServiceImpl.java new file mode 100644 index 0000000..f2f2294 --- /dev/null +++ b/src/main/java/com/yundage/chat/service/impl/EmailServiceImpl.java @@ -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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/yundage/chat/service/impl/SpringAIModelService.java b/src/main/java/com/yundage/chat/service/impl/SpringAIModelService.java new file mode 100644 index 0000000..2e6058d --- /dev/null +++ b/src/main/java/com/yundage/chat/service/impl/SpringAIModelService.java @@ -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 parameters, + Consumer callback, + Runnable onComplete, + Consumer 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 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 parameters) { + + var prompt = chatClient.prompt(); + + if (parameters != null && parameters.containsKey("systemPrompt")) { + prompt.system((String) parameters.get("systemPrompt")); + } + + // 同步调用,直接返回完整结果 + return prompt.user(userMessage) + .call() // 阻塞请求 + .content(); // 取文本 + } +} diff --git a/src/main/java/com/yundage/chat/service/impl/UserServiceImpl.java b/src/main/java/com/yundage/chat/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..3e6c2da --- /dev/null +++ b/src/main/java/com/yundage/chat/service/impl/UserServiceImpl.java @@ -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 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 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; + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1d74b7b..019ee59 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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 \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index f2bd854..63f266d 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -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); \ No newline at end of file +-- ---------------------------- +-- 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;