Merge remote-tracking branch 'origin/master'

This commit is contained in:
yahaozhang
2025-07-28 13:34:09 +08:00
25 changed files with 1606 additions and 702 deletions

View File

@@ -0,0 +1,23 @@
"忘记密码"(Forgot Password)和"重置密码"(Reset Password)是密码恢复流程的两个不同步骤:
1. 忘记密码(forgotPassword):
- 流程的第一步
- 当用户表示忘记密码时触发
- 系统向用户发送带有重置令牌的邮件
- 不直接更改密码
1. 重置密码(resetPassword):
- 流程的第二步
- 用户点击邮件中的链接或输入令牌后使用
- 验证令牌并允许用户设置新密码
- 实际修改用户密码的步骤
简单来说,忘记密码是发起恢复流程,重置密码是完成密码更改。

14
pom.xml
View File

@@ -119,6 +119,13 @@
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty-http</artifactId>
<version>1.1.15</version>
</dependency>
</dependencies>
<build>
@@ -127,6 +134,13 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,28 @@
package com.yundage.chat.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* LLM API配置属性类
*/
@ConfigurationProperties(prefix = "llm")
public class LlmApiProperties {
private String baseUrl;
private String apiKey; // 可选,预留未来认证使用
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
}

View File

@@ -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();
}
}

View File

@@ -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")

View File

@@ -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 = {

View File

@@ -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()));
}
}
}

View File

@@ -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();
}
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,414 @@
package com.yundage.chat.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.yundage.chat.dto.ChatRequest;
import com.yundage.chat.dto.ChatResponse;
import com.yundage.chat.dto.StreamResponse;
import com.yundage.chat.entity.Conversation;
import com.yundage.chat.entity.Message;
import com.yundage.chat.entity.User;
import com.yundage.chat.mapper.ConversationMapper;
import com.yundage.chat.mapper.MessageMapper;
import com.yundage.chat.service.ChatService;
import com.yundage.chat.service.LLMService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.concurrent.DelegatingSecurityContextRunnable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 聊天服务实现类
*/
@Service
public class ChatServiceImpl implements ChatService {
private ConversationMapper conversationMapper;
private MessageMapper messageMapper;
private final LLMService llmService;
private final ObjectMapper mapper = new ObjectMapper();
private final ExecutorService executor = Executors.newCachedThreadPool();
@Autowired
public ChatServiceImpl(@Qualifier("customLLM") LLMService llmService,
ConversationMapper conversationMapper,
MessageMapper messageMapper) {
this.llmService = llmService;
this.conversationMapper = conversationMapper;
this.messageMapper = messageMapper;
}
@Override
@Transactional
public SseEmitter processChatStream(ChatRequest request, User user) {
// 0 表示不超时;如需限制自行调整
SseEmitter emitter = new SseEmitter(0L);
try {
long start = System.currentTimeMillis();
// 1⃣ 取得会话并保存用户消息
Conversation convo = getOrCreateConversation(request, user);
saveUserMessage(convo.getId(), request.getMessage());
// 2⃣ 在独立线程里完成流处理,避免阻塞 servlet 线程
SecurityContext ctx = SecurityContextHolder.getContext();
executor.submit(new DelegatingSecurityContextRunnable(() -> {
StringBuilder full = new StringBuilder();
// 准备额外参数
Map<String, Object> parameters = new HashMap<>();
// 可以根据需要添加特定参数
parameters.put("debug", false);
parameters.put("show_thinking", false);
parameters.put("agent_type","graph_agent");
// 3⃣ 使用封装的LLM服务接口进行流式生成
llmService.streamGenerateText(
request.getMessage(),
parameters,
// 处理每个文本块
chunk -> {
try {
String actualContent = extractActualContent(chunk);
if (actualContent != null) {
full.append(actualContent);
}
StreamResponse resp = StreamResponse.content(chunk);
emitter.send(SseEmitter.event()
.name("message")
.data(mapper.writeValueAsString(resp)));
} catch (Exception io) {
emitter.completeWithError(io);
}
},
// 处理完成事件
() -> {
try {
// 清理最终的内容移除任何残留的JSON或状态标记
String finalContent = cleanupFinalContent(full.toString());
Message assistantMsg = saveAssistantMessage(convo.getId(), finalContent);
convo.setUpdatedAt(LocalDateTime.now());
conversationMapper.update(convo);
List<ChatResponse.ReferenceItem> refs =
"research".equals(request.getMode()) ? generateMockReferences() : List.of();
StreamResponse finish = StreamResponse.finished(
convo.getId().toString(),
assistantMsg.getId().toString(),
refs,
null,
estimateTokens(request.getMessage(), finalContent),
System.currentTimeMillis() - start
);
emitter.send(SseEmitter.event()
.name("finished")
.data(mapper.writeValueAsString(finish)));
emitter.complete();
} catch (Exception e) {
emitter.completeWithError(e);
}
},
// 处理错误事件
err -> {
try {
StreamResponse error = StreamResponse.error("AI 处理失败:" + err.getMessage());
emitter.send(SseEmitter.event()
.name("error")
.data(mapper.writeValueAsString(error)));
} catch (Exception ignore) {}
emitter.completeWithError(err);
}
);
}, ctx));
} catch (Exception ex) {
emitter.completeWithError(ex);
}
return emitter;
}
/**
* 从嵌套的JSON中提取实际内容
* @param jsonStr JSON字符串
* @return 提取的实际内容,或者在无法解析时返回原字符串
*/
private String extractActualContent(String jsonStr) {
try {
// 尝试解析外层JSON
JsonNode node = mapper.readTree(jsonStr);
// 检查是否有状态字段以及是否为token
if (node.has("status") && "token".equals(node.get("status").asText()) && node.has("content")) {
// 解析内层content字段的JSON
String contentText = node.get("content").asText();
if (contentText == null || contentText.isEmpty()) {
return null;
}
try {
JsonNode contentNode = mapper.readTree(contentText);
// 从内层JSON中获取实际内容
if (contentNode.has("content")) {
return contentNode.get("content").asText();
} else if (contentNode.has("status") &&
("done".equals(contentNode.get("status").asText()) ||
"start".equals(contentNode.get("status").asText()))) {
return null;
}
} catch (Exception e) {
// 内层JSON解析失败可能不是JSON格式
return contentText;
}
} else if (node.has("status") &&
("done".equals(node.get("status").asText()) ||
"start".equals(node.get("status").asText()))) {
// 处理开始或结束信号,不需要添加内容
return null;
}
// 如果无法按预期解析返回null避免添加不必要的内容
return null;
} catch (Exception e) {
// 解析错误时返回null
return null;
}
}
/**
* 清理最终内容移除任何残留的JSON或状态标记
* @param content 原始内容
* @return 清理后的内容
*/
private String cleanupFinalContent(String content) {
if (content == null || content.isEmpty()) {
return "";
}
// 移除任何结尾的JSON格式内容
int jsonStart = content.lastIndexOf("{\"status\":");
if (jsonStart >= 0) {
content = content.substring(0, jsonStart);
}
// 移除"**正在分析问题**..."等提示文本
content = content.replaceAll("\\*\\*正在分析问题\\*\\*\\.\\.\\.", "")
.replaceAll("\\*\\*正在检索相关信息\\*\\*\\.\\.\\.", "")
.replaceAll("\\*\\*正在生成回答\\*\\*\\.\\.\\.", "");
// 清理多余的空行
content = content.replaceAll("\\n\\s*\\n\\s*\\n", "\n\n")
.trim();
return content;
}
@Override
@Transactional
public ChatResponse processChat(ChatRequest request, User user) {
long startTime = System.currentTimeMillis();
// 获取或创建会话
Conversation conversation = getOrCreateConversation(request, user);
// 保存用户消息
Message userMessage = saveUserMessage(conversation.getId(), request.getMessage());
// 准备额外参数
Map<String, Object> parameters = new HashMap<>();
if ("research".equals(request.getMode())) {
parameters.put("systemPrompt", "你是一个专注于研究和深度分析的AI助手。提供详细、准确和有参考价值的信息。");
parameters.put("temperature", 0.3); // 更低的温度,提高回答的确定性
} else {
parameters.put("systemPrompt", "你是一个友好、有帮助的AI助手。");
parameters.put("temperature", 0.7); // 默认温度
}
// 使用封装的LLM服务生成回复
String aiResponse = llmService.generateText(request.getMessage(), parameters);
// 保存AI回复消息
Message assistantMessage = saveAssistantMessage(conversation.getId(), aiResponse);
// 更新会话的更新时间
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.update(conversation);
// 构建响应
ChatResponse response = new ChatResponse();
response.setConversationId(conversation.getId().toString());
response.setMessageId(assistantMessage.getId().toString());
response.setAnswer(aiResponse);
response.setTimeMs(System.currentTimeMillis() - startTime);
// 根据模式设置不同的响应内容
if ("research".equals(request.getMode())) {
// 研究模式可能包含引用资料
response.setReferences(generateMockReferences());
response.setReportId(null); // 可以在后续版本中实现报告生成功能
} else {
// 普通聊天模式
response.setReferences(new ArrayList<>());
response.setReportId(null);
}
// 模拟token统计
response.setTokensUsed(estimateTokens(request.getMessage(), aiResponse));
return response;
}
private String[] generateStreamingAIResponse(ChatRequest request) {
// 目前先用单段文本模拟,后续可改为 chatClient.stream() 真正流式
String aiReply = "这是AI的回复内容模拟流式输出。";
return new String[]{aiReply};
}
private Conversation getOrCreateConversation(ChatRequest request, User user) {
if (request.getConversationId() != null) {
// 尝试获取现有会话
try {
Long conversationId = Long.parseLong(request.getConversationId());
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, user.getId());
if (conversation != null) {
return conversation;
}
} catch (NumberFormatException e) {
// 忽略无效的会话ID创建新会话
}
}
// 创建新会话
Conversation conversation = new Conversation();
conversation.setUserId(user.getId());
conversation.setChatMode(request.getMode());
conversation.setTitle(generateConversationTitle(request.getMessage()));
conversation.setCreatedAt(LocalDateTime.now());
conversation.setUpdatedAt(LocalDateTime.now());
conversationMapper.insert(conversation);
return conversation;
}
private Message saveUserMessage(Long conversationId, String content) {
Integer nextSequenceNo = messageMapper.getMaxSequenceNo(conversationId) + 1;
Message message = new Message();
message.setConversationId(conversationId);
message.setSequenceNo(nextSequenceNo);
message.setRole("user");
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
messageMapper.insert(message);
return message;
}
private Message saveAssistantMessage(Long conversationId, String content) {
Integer nextSequenceNo = messageMapper.getMaxSequenceNo(conversationId) + 1;
Message message = new Message();
message.setConversationId(conversationId);
message.setSequenceNo(nextSequenceNo);
message.setRole("assistant");
message.setContent(content);
message.setCreatedAt(LocalDateTime.now());
messageMapper.insert(message);
return message;
}
private String generateAIResponse(ChatRequest request) {
// 准备额外参数
Map<String, Object> parameters = new HashMap<>();
if ("research".equals(request.getMode())) {
parameters.put("systemPrompt", "你是一个专注于研究和深度分析的AI助手。提供详细、准确和有参考价值的信息。");
parameters.put("temperature", 0.3);
} else {
parameters.put("systemPrompt", "你是一个友好、有帮助的AI助手。");
parameters.put("temperature", 0.7);
}
// 使用封装的LLM服务生成回复
return llmService.generateText(request.getMessage(), parameters);
}
private String generateConversationTitle(String message) {
// 简单的标题生成逻辑取前20个字符
if (message.length() <= 20) {
return message;
}
return message.substring(0, 20) + "...";
}
private List<ChatResponse.ReferenceItem> generateMockReferences() {
// 模拟的引用资料,实际项目中从真实的知识库获取
List<ChatResponse.ReferenceItem> references = new ArrayList<>();
references.add(new ChatResponse.ReferenceItem(
"相关研究文档",
"https://example.com/research",
"这是一段引用的文本内容..."
));
return references;
}
private Integer estimateTokens(String userMessage, String aiResponse) {
// 简单的token估算实际项目中需要使用真实的tokenizer
return (userMessage.length() + aiResponse.length()) / 4;
}
@Override
public List<Conversation> getUserConversations(Long userId) {
return conversationMapper.findActiveByUserId(userId);
}
@Override
public List<Message> getConversationMessages(Long conversationId, Long userId) {
// 验证会话是否属于用户
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, userId);
if (conversation == null) {
throw new RuntimeException("会话不存在或无权限访问");
}
return messageMapper.findByConversationId(conversationId);
}
/**
* 删除用户会话(标记为非活跃)
* @param conversationId 会话ID
* @param userId 用户ID
* @return 删除成功返回true失败返回false
*/
@Override
@Transactional
public boolean deleteConversation(Long conversationId, Long userId) {
// 验证会话是否属于用户
Conversation conversation = conversationMapper.findByIdAndUserId(conversationId, userId);
if (conversation == null) {
return false;
}
// 标记会话为非活跃
conversation.setIsActive(0);
conversation.setUpdatedAt(LocalDateTime.now());
return conversationMapper.update(conversation) > 0;
}
}

View File

@@ -0,0 +1,103 @@
package com.yundage.chat.service.impl;
import com.yundage.chat.service.LLMService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
/**
* 自定义协议的LLM微服务调用实现
*/
@Service
@Qualifier("customLLM")
public class CustomAPIModelService implements LLMService {
private final WebClient llmClient;
public CustomAPIModelService(@Qualifier("llmWebClient") WebClient llmClient) {
this.llmClient = llmClient;
}
/**
* 阻塞式调用,返回完整文本
*/
@Override
public String generateText(String userMessage, Map<String, Object> params) {
// 构建请求体
Map<String, Object> body = new HashMap<>();
body.put("message", userMessage);
body.put("debug", false);
body.put("show_thinking", false);
body.put("agent_type", "graph_agent");
// 打印调试信息
System.out.println("发送请求体: " + body);
return llmClient.post()
.uri("/chat")
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(body)) // 使用BodyInserters确保正确发送
.retrieve()
.bodyToMono(String.class)
.block();
}
/**
* 流式调用,通过回调函数处理响应片段
*/
@Override
public void streamGenerateText(String userMessage,
Map<String, Object> params,
Consumer<String> onChunk,
Runnable onComplete,
Consumer<Throwable> onError) {
// --------- 1. 组装 JSON ---------- //
Map<String, Object> body = new HashMap<>();
body.put("message", userMessage);
body.put("session_id", params.getOrDefault("session_id",
UUID.randomUUID().toString())); // 必要字段
body.put("debug", false);
body.put("show_thinking", false);
body.put("agent_type", "graph_agent");
System.out.println("[SSEREQ] " + 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("[SSERESP] " + chunk);
onChunk.accept(chunk);
})
.doOnComplete(onComplete)
.doOnError(e -> {
System.err.println("[SSEERR] " + e.getMessage());
onError.accept(e);
})
.subscribe(); // 订阅并开始请求
}
/**
* 获取会话ID如果参数中有则使用参数中的否则生成一个新的
*/
private String getSessionId(Map<String, Object> params) {
if (params != null && params.containsKey("sessionId")) {
return params.get("sessionId").toString();
}
return "debug-session-" + UUID.randomUUID().toString().substring(0, 8);
}
}

View File

@@ -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());
}
}
}

View File

@@ -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.0M6 的 LLM 服务
*/
@Service
public class SpringAIModelService implements LLMService {
private final ChatClient chatClient;
public SpringAIModelService(ChatClient.Builder builder) {
// Spring Boot 已为 Builder 注入模型、APIKey 等配置
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(); // 取文本
}
}

View 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;
}
}
}

View File

@@ -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

View File

@@ -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;