更新API响应结构,添加用户登出和会话删除功能
- 在ApiResponse类中添加conflict方法以处理冲突响应 - 在UserController中实现用户登出功能,返回标准化的API响应 - 在ChatController中实现会话删除功能,返回相应的成功或错误信息 - 更新ErrorCode类,添加CONFLICT错误码以支持新的响应类型 - 修改OpenApiConfig中的API文档标题和描述 此提交增强了用户体验,提供了更清晰的错误处理和API文档。
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):
|
||||
|
||||
- 流程的第二步
|
||||
|
||||
- 用户点击邮件中的链接或输入令牌后使用
|
||||
|
||||
- 验证令牌并允许用户设置新密码
|
||||
|
||||
- 实际修改用户密码的步骤
|
||||
|
||||
简单来说,忘记密码是发起恢复流程,重置密码是完成密码更改。
|
||||
3
pom.xml
3
pom.xml
@@ -119,6 +119,9 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
334
src/main/java/com/yundage/chat/service/impl/ChatServiceImpl.java
Normal file
334
src/main/java/com/yundage/chat/service/impl/ChatServiceImpl.java
Normal file
@@ -0,0 +1,334 @@
|
||||
package com.yundage.chat.service.impl;
|
||||
|
||||
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 com.yundage.chat.service.ChatService;
|
||||
import com.yundage.chat.service.LLMService;
|
||||
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 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(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<>();
|
||||
// 可以根据需要添加特定参数
|
||||
if ("research".equals(request.getMode())) {
|
||||
parameters.put("systemPrompt", "你是一个专注于研究和深度分析的AI助手。提供详细、准确和有参考价值的信息。");
|
||||
parameters.put("temperature", 0.3); // 更低的温度,提高回答的确定性
|
||||
} else {
|
||||
parameters.put("systemPrompt", "你是一个友好、有帮助的AI助手。");
|
||||
parameters.put("temperature", 0.7); // 默认温度
|
||||
}
|
||||
|
||||
// 3️⃣ 使用封装的LLM服务接口进行流式生成
|
||||
llmService.streamGenerateText(
|
||||
request.getMessage(),
|
||||
parameters,
|
||||
// 处理每个文本块
|
||||
chunk -> {
|
||||
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);
|
||||
}
|
||||
},
|
||||
// 处理完成事件
|
||||
() -> {
|
||||
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);
|
||||
}
|
||||
},
|
||||
// 处理错误事件
|
||||
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;
|
||||
}
|
||||
|
||||
@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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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