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