diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 01a2859..80afb33 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -57,6 +57,14 @@ dependencies { // Web3j for Ethereum wallet and EIP-712 signing implementation("org.web3j:core:5.0.0") + // JWT + implementation("io.jsonwebtoken:jjwt-api:0.12.3") + implementation("io.jsonwebtoken:jjwt-impl:0.12.3") + implementation("io.jsonwebtoken:jjwt-jackson:0.12.3") + + // BCrypt for password encryption + implementation("org.springframework.security:spring-security-crypto:6.2.2") + // Logging implementation("org.slf4j:slf4j-api") diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt new file mode 100644 index 0000000..e7922ea --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/JwtAuthenticationInterceptor.kt @@ -0,0 +1,104 @@ +package com.wrbug.polymarketbot.config + +import com.wrbug.polymarketbot.dto.ApiResponse +import com.wrbug.polymarketbot.util.JwtUtils +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.http.MediaType +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +/** + * JWT认证拦截器 + */ +@Component +class JwtAuthenticationInterceptor( + private val jwtUtils: JwtUtils +) : HandlerInterceptor { + + private val logger = LoggerFactory.getLogger(JwtAuthenticationInterceptor::class.java) + private val objectMapper = ObjectMapper() + + // 不需要鉴权的路径 + private val excludePaths = setOf( + "/api/auth/login", + "/api/auth/reset-password", + "/api/auth/check-first-use" + ) + + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any + ): Boolean { + val path = request.requestURI + val method = request.method + + // 只拦截POST请求 + if (method != "POST") { + return true + } + + // 排除不需要鉴权的路径 + if (excludePaths.contains(path)) { + return true + } + + // 只拦截 /api/** 路径 + if (!path.startsWith("/api/")) { + return true + } + + // 从请求头获取token + val authHeader = request.getHeader("Authorization") + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + sendAuthError(response, "缺少认证令牌") + return false + } + + val token = authHeader.substring(7) // 移除 "Bearer " 前缀 + + // 验证token + if (!jwtUtils.validateToken(token)) { + logger.warn("Token验证失败: path=$path") + sendAuthError(response, "认证令牌无效或已过期") + return false + } + + // 检查是否需要刷新token(使用超过1天但未过期) + if (jwtUtils.isTokenExpiring(token)) { + val username = jwtUtils.getUsernameFromToken(token) + if (username != null) { + val newToken = jwtUtils.generateToken(username) + // 在响应头中返回新token + response.setHeader("X-New-Token", newToken) + logger.debug("Token自动刷新: username=$username, path=$path") + } + } + + // 将用户名存入Request属性,供后续使用 + val username = jwtUtils.getUsernameFromToken(token) + if (username != null) { + request.setAttribute("username", username) + } + + return true + } + + /** + * 发送认证错误响应 + */ + private fun sendAuthError(response: HttpServletResponse, message: String) { + response.status = HttpServletResponse.SC_OK + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.characterEncoding = "UTF-8" + + val apiResponse: ApiResponse = ApiResponse.authError(message) + val json = objectMapper.writeValueAsString(apiResponse) + response.writer.write(json) + response.writer.flush() + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt new file mode 100644 index 0000000..a600a61 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebMvcConfig.kt @@ -0,0 +1,21 @@ +package com.wrbug.polymarketbot.config + +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +/** + * Web MVC 配置 + * 注册JWT认证拦截器 + */ +@Configuration +class WebMvcConfig( + private val jwtAuthenticationInterceptor: JwtAuthenticationInterceptor +) : WebMvcConfigurer { + + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(jwtAuthenticationInterceptor) + .addPathPatterns("/api/**") + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt new file mode 100644 index 0000000..0145160 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketAuthInterceptor.kt @@ -0,0 +1,93 @@ +package com.wrbug.polymarketbot.config + +import com.wrbug.polymarketbot.util.JwtUtils +import org.slf4j.LoggerFactory +import org.springframework.http.server.ServerHttpRequest +import org.springframework.http.server.ServerHttpResponse +import org.springframework.stereotype.Component +import org.springframework.web.socket.WebSocketHandler +import org.springframework.web.socket.server.HandshakeInterceptor + +/** + * WebSocket 握手拦截器 + * 用于验证 JWT token + */ +@Component +class WebSocketAuthInterceptor( + private val jwtUtils: JwtUtils +) : HandshakeInterceptor { + + private val logger = LoggerFactory.getLogger(WebSocketAuthInterceptor::class.java) + + override fun beforeHandshake( + request: ServerHttpRequest, + response: ServerHttpResponse, + wsHandler: WebSocketHandler, + attributes: MutableMap + ): Boolean { + // 从查询参数或请求头获取 token + val token = getTokenFromRequest(request) + + if (token == null) { + logger.warn("WebSocket 连接缺少认证令牌: ${request.uri}") + response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) + return false + } + + // 验证 token + if (!jwtUtils.validateToken(token)) { + logger.warn("WebSocket 连接 token 验证失败: ${request.uri}") + response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) + return false + } + + // 获取用户名并存入 attributes,供后续使用 + val username = jwtUtils.getUsernameFromToken(token) + if (username != null) { + attributes["username"] = username + logger.debug("WebSocket 连接认证成功: username=$username, uri=${request.uri}") + } else { + logger.warn("WebSocket 连接无法获取用户名: ${request.uri}") + response.setStatusCode(org.springframework.http.HttpStatus.UNAUTHORIZED) + return false + } + + return true + } + + override fun afterHandshake( + request: ServerHttpRequest, + response: ServerHttpResponse, + wsHandler: WebSocketHandler, + exception: Exception? + ) { + // 握手后处理(如果需要) + } + + /** + * 从请求中获取 token + * 支持从查询参数 token 或请求头 Authorization 获取 + */ + private fun getTokenFromRequest(request: ServerHttpRequest): String? { + // 优先从查询参数获取 + val queryParams = request.uri.query + if (queryParams != null) { + val params = queryParams.split("&") + for (param in params) { + val parts = param.split("=", limit = 2) + if (parts.size == 2 && parts[0] == "token") { + return parts[1] + } + } + } + + // 从请求头获取 + val authHeader = request.headers.getFirst("Authorization") + if (authHeader != null && authHeader.startsWith("Bearer ")) { + return authHeader.substring(7) + } + + return null + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt index cc3dd61..d258ee6 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/config/WebSocketConfig.kt @@ -15,17 +15,21 @@ import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry @EnableWebSocket class WebSocketConfig( private val polymarketWebSocketHandler: PolymarketWebSocketHandler, - private val unifiedWebSocketHandler: UnifiedWebSocketHandler + private val unifiedWebSocketHandler: UnifiedWebSocketHandler, + private val webSocketAuthInterceptor: WebSocketAuthInterceptor ) : WebSocketConfigurer { override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { // Polymarket RTDS 转发端点(转发外部 Polymarket 实时数据流) + // 注意:此端点不需要鉴权,因为它只是转发外部数据 registry.addHandler(polymarketWebSocketHandler, "/ws/polymarket") .setAllowedOrigins("*") // 生产环境应该配置具体的域名 // 统一 WebSocket 端点(所有推送服务统一使用此路径,通过 channel 区分) // 支持的频道:position(仓位推送)、order(订单推送,待实现)等 + // 需要 JWT 鉴权 registry.addHandler(unifiedWebSocketHandler, "/ws") + .addInterceptors(webSocketAuthInterceptor) // 添加鉴权拦截器 .setAllowedOrigins("*") // 生产环境应该配置具体的域名 } } diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AuthController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AuthController.kt new file mode 100644 index 0000000..090b5b3 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/AuthController.kt @@ -0,0 +1,134 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.AuthService +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 认证控制器 + */ +@RestController +@RequestMapping("/api/auth") +class AuthController( + private val authService: AuthService +) { + + private val logger = LoggerFactory.getLogger(AuthController::class.java) + + /** + * 登录接口 + */ + @PostMapping("/login") + fun login(@RequestBody request: LoginRequest): ResponseEntity> { + return try { + if (request.username.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("用户名不能为空")) + } + if (request.password.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("密码不能为空")) + } + + val result = authService.login(request.username, request.password) + result.fold( + onSuccess = { loginResponse -> + ResponseEntity.ok(ApiResponse.success(loginResponse)) + }, + onFailure = { e -> + logger.error("登录失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> { + if (e.message == ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message) { + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.code, e.message ?: "用户名或密码错误")) + } else { + ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + } + } + else -> ResponseEntity.ok(ApiResponse.serverError("登录失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("登录异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("登录失败: ${e.message}")) + } + } + + /** + * 重置密码接口 + */ + @PostMapping("/reset-password") + fun resetPassword( + @RequestBody request: ResetPasswordRequest, + httpRequest: HttpServletRequest + ): ResponseEntity> { + return try { + if (request.resetKey.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("重置密钥不能为空")) + } + if (request.username.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("用户名不能为空")) + } + if (request.newPassword.isBlank()) { + return ResponseEntity.ok(ApiResponse.paramError("新密码不能为空")) + } + + val result = authService.resetPassword( + resetKey = request.resetKey, + username = request.username, + newPassword = request.newPassword, + request = httpRequest + ) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("重置密码失败: ${e.message}", e) + // 统一返回"重置失败",不暴露具体错误原因(安全考虑) + // 但密码强度错误可以提示,因为这是输入格式问题 + when (e) { + is IllegalArgumentException -> { + if (e.message == ErrorCode.AUTH_PASSWORD_WEAK.message) { + // 密码强度错误可以提示 + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PASSWORD_WEAK.code, e.message ?: "密码长度不符合要求")) + } else { + // 其他错误统一返回"重置失败" + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "重置失败")) + } + } + is IllegalStateException -> { + // 频率限制等错误统一返回"重置失败" + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "重置失败")) + } + else -> { + ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "重置失败")) + } + } + } + ) + } catch (e: Exception) { + logger.error("重置密码异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("重置密码失败: ${e.message}")) + } + } + + /** + * 检查是否首次使用接口 + */ + @PostMapping("/check-first-use") + fun checkFirstUse(): ResponseEntity> { + return try { + val isFirstUse = authService.isFirstUse() + ResponseEntity.ok(ApiResponse.success(CheckFirstUseResponse(isFirstUse = isFirstUse))) + } catch (e: Exception) { + logger.error("检查首次使用异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("检查首次使用失败: ${e.message}")) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/UserController.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/UserController.kt new file mode 100644 index 0000000..2cc7394 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/controller/UserController.kt @@ -0,0 +1,205 @@ +package com.wrbug.polymarketbot.controller + +import com.wrbug.polymarketbot.dto.* +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.service.UserService +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * 用户管理控制器 + */ +@RestController +@RequestMapping("/api/users") +class UserController( + private val userService: UserService +) { + + private val logger = LoggerFactory.getLogger(UserController::class.java) + + /** + * 获取当前用户名(从Request属性中获取) + */ + private fun getCurrentUsername(request: HttpServletRequest): String? { + return request.getAttribute("username") as? String + } + + /** + * 验证是否为默认账户 + */ + private fun checkDefaultUser(request: HttpServletRequest): String? { + val username = getCurrentUsername(request) + if (username == null) { + return "未获取到用户信息" + } + if (!userService.isDefaultUser(username)) { + return "只有默认账户可以执行此操作" + } + return null + } + + /** + * 获取用户列表 + * 所有用户都可以访问,但非默认账户只能看到自己的信息 + */ + @PostMapping("/list") + fun getUserList(request: HttpServletRequest): ResponseEntity>> { + return try { + val currentUsername = getCurrentUsername(request) + if (currentUsername == null) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "未获取到用户信息")) + } + + val users = userService.getUserList(currentUsername) + ResponseEntity.ok(ApiResponse.success(users)) + } catch (e: Exception) { + logger.error("获取用户列表异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("获取用户列表失败: ${e.message}")) + } + } + + /** + * 创建用户 + */ + @PostMapping("/create") + fun createUser( + @RequestBody requestBody: UserCreateRequest, + httpRequest: HttpServletRequest + ): ResponseEntity> { + return try { + val error = checkDefaultUser(httpRequest) + if (error != null) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, error)) + } + + val currentUsername = getCurrentUsername(httpRequest)!! + val result = userService.createUser(requestBody, currentUsername) + + result.fold( + onSuccess = { user -> + ResponseEntity.ok(ApiResponse.success(user)) + }, + onFailure = { e -> + logger.error("创建用户失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, e.message ?: "权限不足")) + else -> ResponseEntity.ok(ApiResponse.serverError("创建用户失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("创建用户异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("创建用户失败: ${e.message}")) + } + } + + /** + * 更新用户密码 + */ + @PostMapping("/update-password") + fun updateUserPassword( + @RequestBody requestBody: UserUpdatePasswordRequest, + httpRequest: HttpServletRequest + ): ResponseEntity> { + return try { + val error = checkDefaultUser(httpRequest) + if (error != null) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, error)) + } + + val currentUsername = getCurrentUsername(httpRequest)!! + val result = userService.updateUserPassword(requestBody, currentUsername) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("更新用户密码失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, e.message ?: "权限不足")) + else -> ResponseEntity.ok(ApiResponse.serverError("更新用户密码失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("更新用户密码异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("更新用户密码失败: ${e.message}")) + } + } + + /** + * 用户修改自己的密码 + */ + @PostMapping("/update-own-password") + fun updateOwnPassword( + @RequestBody requestBody: UserUpdateOwnPasswordRequest, + httpRequest: HttpServletRequest + ): ResponseEntity> { + return try { + val currentUsername = getCurrentUsername(httpRequest) + if (currentUsername == null) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_ERROR.code, "未获取到用户信息")) + } + + val result = userService.updateOwnPassword(requestBody.newPassword, currentUsername) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("修改自己密码失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + else -> ResponseEntity.ok(ApiResponse.serverError("修改密码失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("修改自己密码异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("修改密码失败: ${e.message}")) + } + } + + /** + * 删除用户 + */ + @PostMapping("/delete") + fun deleteUser( + @RequestBody requestBody: UserDeleteRequest, + httpRequest: HttpServletRequest + ): ResponseEntity> { + return try { + val error = checkDefaultUser(httpRequest) + if (error != null) { + return ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, error)) + } + + val currentUsername = getCurrentUsername(httpRequest)!! + val result = userService.deleteUser(requestBody.userId, currentUsername) + + result.fold( + onSuccess = { + ResponseEntity.ok(ApiResponse.success(Unit)) + }, + onFailure = { e -> + logger.error("删除用户失败: ${e.message}", e) + when (e) { + is IllegalArgumentException -> ResponseEntity.ok(ApiResponse.paramError(e.message ?: "参数错误")) + is IllegalStateException -> ResponseEntity.ok(ApiResponse.error(ErrorCode.AUTH_PERMISSION_DENIED.code, e.message ?: "权限不足")) + else -> ResponseEntity.ok(ApiResponse.serverError("删除用户失败: ${e.message}")) + } + } + ) + } catch (e: Exception) { + logger.error("删除用户异常: ${e.message}", e) + ResponseEntity.ok(ApiResponse.serverError("删除用户失败: ${e.message}")) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthRequest.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthRequest.kt new file mode 100644 index 0000000..c2037d5 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthRequest.kt @@ -0,0 +1,19 @@ +package com.wrbug.polymarketbot.dto + +/** + * 登录请求 + */ +data class LoginRequest( + val username: String, + val password: String +) + +/** + * 重置密码请求 + */ +data class ResetPasswordRequest( + val resetKey: String, + val username: String, + val newPassword: String +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt new file mode 100644 index 0000000..213553b --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/AuthResponse.kt @@ -0,0 +1,16 @@ +package com.wrbug.polymarketbot.dto + +/** + * 登录响应 + */ +data class LoginResponse( + val token: String +) + +/** + * 检查首次使用响应 + */ +data class CheckFirstUseResponse( + val isFirstUse: Boolean +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/UserDto.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/UserDto.kt new file mode 100644 index 0000000..9d470b4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/UserDto.kt @@ -0,0 +1,13 @@ +package com.wrbug.polymarketbot.dto + +/** + * 用户DTO + */ +data class UserDto( + val id: Long, + val username: String, + val isDefault: Boolean, + val createdAt: Long, + val updatedAt: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/UserRequest.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/UserRequest.kt new file mode 100644 index 0000000..87e86c4 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/dto/UserRequest.kt @@ -0,0 +1,32 @@ +package com.wrbug.polymarketbot.dto + +/** + * 创建用户请求 + */ +data class UserCreateRequest( + val username: String, + val password: String +) + +/** + * 更新用户密码请求(管理员修改其他用户密码) + */ +data class UserUpdatePasswordRequest( + val userId: Long, + val newPassword: String +) + +/** + * 用户修改自己密码请求 + */ +data class UserUpdateOwnPasswordRequest( + val newPassword: String +) + +/** + * 删除用户请求 + */ +data class UserDeleteRequest( + val userId: Long +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/User.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/User.kt new file mode 100644 index 0000000..431b24a --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/entity/User.kt @@ -0,0 +1,30 @@ +package com.wrbug.polymarketbot.entity + +import jakarta.persistence.* + +/** + * 用户实体(用于JWT登录鉴权) + */ +@Entity +@Table(name = "users") +data class User( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(name = "username", unique = true, nullable = false, length = 50) + val username: String, + + @Column(name = "password", nullable = false, length = 255) + val password: String, // BCrypt加密后的密码 + + @Column(name = "is_default", nullable = false) + val isDefault: Boolean = false, // 是否默认账户(首次创建的用户) + + @Column(name = "created_at", nullable = false) + val createdAt: Long = System.currentTimeMillis(), + + @Column(name = "updated_at", nullable = false) + var updatedAt: Long = System.currentTimeMillis() +) + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt index 28b82b8..dd37732 100644 --- a/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/enums/ErrorCode.kt @@ -75,6 +75,11 @@ enum class ErrorCode( AUTH_API_SECRET_INVALID(2006, "API Secret 无效"), AUTH_API_PASSPHRASE_INVALID(2007, "API Passphrase 无效"), AUTH_API_CREDENTIALS_MISSING(2008, "API 凭证未配置"), + AUTH_USERNAME_OR_PASSWORD_ERROR(2009, "用户名或密码错误"), + AUTH_RESET_KEY_INVALID(2010, "重置密钥错误"), + AUTH_RESET_PASSWORD_RATE_LIMIT(2011, "频率限制:1分钟内最多尝试3次,请稍后再试"), + AUTH_USER_NOT_FOUND(2012, "用户不存在"), + AUTH_PASSWORD_WEAK(2013, "密码长度不符合要求,至少6位"), // ==================== 资源不存在 (3001-3999) ==================== NOT_FOUND(3001, "资源不存在"), diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/UserRepository.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/UserRepository.kt new file mode 100644 index 0000000..86b92c9 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/repository/UserRepository.kt @@ -0,0 +1,33 @@ +package com.wrbug.polymarketbot.repository + +import com.wrbug.polymarketbot.entity.User +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +/** + * 用户 Repository + */ +@Repository +interface UserRepository : JpaRepository { + + /** + * 根据用户名查找用户 + */ + fun findByUsername(username: String): User? + + /** + * 检查用户名是否存在 + */ + fun existsByUsername(username: String): Boolean + + /** + * 查找默认账户 + */ + fun findByIsDefaultTrue(): User? + + /** + * 查找所有用户,按创建时间排序 + */ + fun findAllByOrderByCreatedAtAsc(): List +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AuthService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AuthService.kt new file mode 100644 index 0000000..eebe6f8 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/AuthService.kt @@ -0,0 +1,196 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.CheckFirstUseResponse +import com.wrbug.polymarketbot.dto.LoginResponse +import com.wrbug.polymarketbot.entity.User +import com.wrbug.polymarketbot.enums.ErrorCode +import com.wrbug.polymarketbot.repository.UserRepository +import com.wrbug.polymarketbot.util.JwtUtils +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 认证服务 + */ +@Service +class AuthService( + private val userRepository: UserRepository, + private val jwtUtils: JwtUtils, + private val rateLimitService: RateLimitService +) { + + private val logger = LoggerFactory.getLogger(AuthService::class.java) + private val passwordEncoder = BCryptPasswordEncoder() + + @Value("\${admin.reset-password.key}") + private lateinit var resetPasswordKey: String + + /** + * 登录 + */ + fun login(username: String, password: String): Result { + return try { + val user = userRepository.findByUsername(username) + ?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) + + // 验证密码 + if (!passwordEncoder.matches(password, user.password)) { + logger.warn("登录失败:密码错误,username=$username") + return Result.failure(IllegalArgumentException(ErrorCode.AUTH_USERNAME_OR_PASSWORD_ERROR.message)) + } + + // 生成JWT token + val token = jwtUtils.generateToken(username) + + logger.info("用户登录成功:username=$username") + Result.success(LoginResponse(token = token)) + } catch (e: Exception) { + logger.error("登录异常:username=$username", e) + Result.failure(e) + } + } + + /** + * 重置密码 + */ + @Transactional + fun resetPassword( + resetKey: String, + username: String, + newPassword: String, + request: HttpServletRequest + ): Result { + return try { + // 先检查频率限制(全局限制,不按IP) + rateLimitService.checkResetPasswordRateLimit().fold( + onSuccess = { }, + onFailure = { e -> + logger.warn("重置密码频率限制触发:username=$username") + return Result.failure(IllegalStateException("重置失败")) + } + ) + + // 验证重置密钥 + if (resetKey != resetPasswordKey) { + logger.warn("重置密码失败:重置密钥错误,username=$username") + return Result.failure(IllegalArgumentException("重置失败")) + } + + // 验证密码强度 + if (!checkPasswordStrength(newPassword)) { + logger.warn("重置密码失败:密码强度不符合要求,username=$username") + return Result.failure(IllegalArgumentException(ErrorCode.AUTH_PASSWORD_WEAK.message)) + } + + // 检查用户是否存在 + val existingUser = userRepository.findByUsername(username) + + if (existingUser != null) { + // 用户存在,更新密码 + val encodedPassword = passwordEncoder.encode(newPassword) + val updatedUser = existingUser.copy( + password = encodedPassword, + updatedAt = System.currentTimeMillis() + ) + userRepository.save(updatedUser) + logger.info("密码重置成功:username=$username") + } else { + // 用户不存在,检查是否是首次使用 + val isFirstUse = userRepository.count() == 0L + if (isFirstUse) { + // 首次使用,创建新用户(设置为默认账户) + val encodedPassword = passwordEncoder.encode(newPassword) + val newUser = User( + username = username, + password = encodedPassword, + isDefault = true, // 首次创建的用户为默认账户 + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + userRepository.save(newUser) + logger.info("首次使用,创建默认账户成功:username=$username") + } else { + // 不是首次使用,用户不存在 + logger.warn("重置密码失败:用户不存在,username=$username") + return Result.failure(IllegalArgumentException("重置失败")) + } + } + + Result.success(Unit) + } catch (e: Exception) { + logger.error("重置密码异常:username=$username", e) + Result.failure(e) + } + } + + /** + * 刷新token + */ + fun refreshToken(token: String): Result { + return try { + if (!jwtUtils.validateToken(token)) { + return Result.failure(IllegalArgumentException(ErrorCode.AUTH_TOKEN_INVALID.message)) + } + + if (!jwtUtils.isTokenExpiring(token)) { + // 不需要刷新,返回原token + return Result.success(token) + } + + // 获取用户名并生成新token + val username = jwtUtils.getUsernameFromToken(token) + ?: return Result.failure(IllegalArgumentException(ErrorCode.AUTH_TOKEN_INVALID.message)) + + val newToken = jwtUtils.generateToken(username) + logger.debug("Token刷新成功:username=$username") + Result.success(newToken) + } catch (e: Exception) { + logger.error("刷新token异常", e) + Result.failure(e) + } + } + + /** + * 检查是否首次使用(数据库中是否有用户) + */ + fun isFirstUse(): Boolean { + return userRepository.count() == 0L + } + + /** + * 验证密码强度 + * 至少6位 + */ + private fun checkPasswordStrength(password: String): Boolean { + return password.length >= 6 + } + + /** + * 获取客户端IP地址 + */ + private fun getClientIpAddress(request: HttpServletRequest): String { + var ip = request.getHeader("X-Forwarded-For") + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.getHeader("X-Real-IP") + } + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.getHeader("Proxy-Client-IP") + } + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.getHeader("WL-Proxy-Client-IP") + } + if (ip.isNullOrBlank() || "unknown".equals(ip, ignoreCase = true)) { + ip = request.remoteAddr + } + // 处理多个IP的情况(X-Forwarded-For可能包含多个IP) + if (ip.contains(",")) { + ip = ip.split(",")[0].trim() + } + return ip + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/RateLimitService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/RateLimitService.kt new file mode 100644 index 0000000..053dbb2 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/RateLimitService.kt @@ -0,0 +1,52 @@ +package com.wrbug.polymarketbot.service + +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import java.util.concurrent.atomic.AtomicReference + +/** + * 频率限制服务(使用内存缓存,全局限制) + */ +@Service +class RateLimitService { + + private val logger = LoggerFactory.getLogger(RateLimitService::class.java) + + @Value("\${rate-limit.reset-password.max-attempts:3}") + private var maxAttempts: Int = 3 + + @Value("\${rate-limit.reset-password.window-seconds:60}") + private var windowSeconds: Long = 60 + + // 全局尝试记录列表(时间戳),所有请求共享 + private val resetPasswordAttempts = AtomicReference>(mutableListOf()) + + /** + * 检查重置密码频率限制(全局限制,不按IP) + * @return Result,如果超过限制则返回失败 + */ + fun checkResetPasswordRateLimit(): Result { + val now = System.currentTimeMillis() + val windowStart = now - (windowSeconds * 1000) + + // 获取当前尝试记录列表 + val attempts = resetPasswordAttempts.get() + + // 清理过期记录(超过时间窗口的记录) + val validAttempts = attempts.filter { it >= windowStart }.toMutableList() + + // 检查是否超过限制 + if (validAttempts.size >= maxAttempts) { + logger.warn("重置密码频率限制触发: attempts=${validAttempts.size}/$maxAttempts") + return Result.failure(IllegalStateException("频率限制:1分钟内最多尝试${maxAttempts}次,请稍后再试")) + } + + // 记录本次尝试 + validAttempts.add(now) + resetPasswordAttempts.set(validAttempts) + + return Result.success(Unit) + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/service/UserService.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/UserService.kt new file mode 100644 index 0000000..066ea98 --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/service/UserService.kt @@ -0,0 +1,245 @@ +package com.wrbug.polymarketbot.service + +import com.wrbug.polymarketbot.dto.UserCreateRequest +import com.wrbug.polymarketbot.dto.UserDto +import com.wrbug.polymarketbot.dto.UserUpdatePasswordRequest +import com.wrbug.polymarketbot.entity.User +import com.wrbug.polymarketbot.repository.UserRepository +import org.slf4j.LoggerFactory +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * 用户管理服务 + */ +@Service +class UserService( + private val userRepository: UserRepository +) { + + private val logger = LoggerFactory.getLogger(UserService::class.java) + private val passwordEncoder = BCryptPasswordEncoder() + + /** + * 检查当前用户是否为默认账户 + */ + fun isDefaultUser(username: String): Boolean { + val user = userRepository.findByUsername(username) ?: return false + return user.isDefault + } + + /** + * 获取用户列表 + * @param currentUsername 当前登录用户名 + * @return 如果是默认账户,返回所有用户;否则只返回当前用户 + */ + fun getUserList(currentUsername: String): List { + val isDefault = isDefaultUser(currentUsername) + + if (isDefault) { + // 默认账户:返回所有用户 + val users = userRepository.findAllByOrderByCreatedAtAsc() + return users.map { user -> + UserDto( + id = user.id!!, + username = user.username, + isDefault = user.isDefault, + createdAt = user.createdAt, + updatedAt = user.updatedAt + ) + } + } else { + // 非默认账户:只返回当前用户 + val user = userRepository.findByUsername(currentUsername) + if (user != null) { + return listOf( + UserDto( + id = user.id!!, + username = user.username, + isDefault = user.isDefault, + createdAt = user.createdAt, + updatedAt = user.updatedAt + ) + ) + } + return emptyList() + } + } + + /** + * 创建用户 + * 注意:此方法只允许默认账户调用 + */ + @Transactional + fun createUser(request: UserCreateRequest, currentUsername: String): Result { + return try { + // 验证当前用户是否为默认账户(双重验证,确保安全) + val currentUser = userRepository.findByUsername(currentUsername) + ?: return Result.failure(IllegalArgumentException("当前用户不存在")) + + if (!currentUser.isDefault) { + logger.warn("非默认账户尝试创建用户:currentUser=$currentUsername") + return Result.failure(IllegalStateException("只有默认账户可以创建用户")) + } + + // 验证用户名 + if (request.username.isBlank()) { + return Result.failure(IllegalArgumentException("用户名不能为空")) + } + + // 验证密码 + if (request.password.length < 6) { + return Result.failure(IllegalArgumentException("密码长度不符合要求,至少6位")) + } + + // 检查用户名是否已存在 + if (userRepository.existsByUsername(request.username)) { + return Result.failure(IllegalArgumentException("用户名已存在")) + } + + // 创建用户 + val encodedPassword = passwordEncoder.encode(request.password) + val newUser = User( + username = request.username, + password = encodedPassword, + isDefault = false, + createdAt = System.currentTimeMillis(), + updatedAt = System.currentTimeMillis() + ) + val savedUser = userRepository.save(newUser) + + logger.info("创建用户成功:username=${request.username}, createdBy=$currentUsername") + Result.success(UserDto( + id = savedUser.id!!, + username = savedUser.username, + isDefault = savedUser.isDefault, + createdAt = savedUser.createdAt, + updatedAt = savedUser.updatedAt + )) + } catch (e: Exception) { + logger.error("创建用户异常:username=${request.username}", e) + Result.failure(e) + } + } + + /** + * 更新用户密码(管理员修改其他用户密码) + * 注意:此方法只允许默认账户调用,且只能修改非默认账户的密码 + */ + @Transactional + fun updateUserPassword(request: UserUpdatePasswordRequest, currentUsername: String): Result { + return try { + // 验证当前用户是否为默认账户(双重验证,确保安全) + val currentUser = userRepository.findByUsername(currentUsername) + ?: return Result.failure(IllegalArgumentException("当前用户不存在")) + + if (!currentUser.isDefault) { + logger.warn("非默认账户尝试更新用户密码:currentUser=$currentUsername") + return Result.failure(IllegalStateException("只有默认账户可以更新用户密码")) + } + + // 验证密码 + if (request.newPassword.length < 6) { + return Result.failure(IllegalArgumentException("密码长度不符合要求,至少6位")) + } + + // 查找目标用户 + val targetUser = userRepository.findById(request.userId).orElse(null) + ?: return Result.failure(IllegalArgumentException("用户不存在")) + + // 不能修改默认账户的密码(通过用户管理接口) + if (targetUser.isDefault) { + return Result.failure(IllegalArgumentException("不能修改默认账户的密码")) + } + + // 更新密码 + val encodedPassword = passwordEncoder.encode(request.newPassword) + val updatedUser = targetUser.copy( + password = encodedPassword, + updatedAt = System.currentTimeMillis() + ) + userRepository.save(updatedUser) + + logger.info("更新用户密码成功:userId=${request.userId}, username=${targetUser.username}, updatedBy=$currentUsername") + Result.success(Unit) + } catch (e: Exception) { + logger.error("更新用户密码异常:userId=${request.userId}, currentUser=$currentUsername", e) + Result.failure(e) + } + } + + /** + * 用户修改自己的密码 + * 注意:此方法从JWT中获取用户名,确保用户只能修改自己的密码 + */ + @Transactional + fun updateOwnPassword(newPassword: String, currentUsername: String): Result { + return try { + // 验证密码 + if (newPassword.length < 6) { + return Result.failure(IllegalArgumentException("密码长度不符合要求,至少6位")) + } + + // 从数据库查找当前用户(确保用户存在,且用户名来自JWT,不可篡改) + val user = userRepository.findByUsername(currentUsername) + ?: return Result.failure(IllegalArgumentException("用户不存在")) + + // 更新密码(只更新当前用户的密码,不依赖任何请求参数中的用户ID) + val encodedPassword = passwordEncoder.encode(newPassword) + val updatedUser = user.copy( + password = encodedPassword, + updatedAt = System.currentTimeMillis() + ) + userRepository.save(updatedUser) + + logger.info("用户修改自己密码成功:username=$currentUsername, userId=${user.id}") + Result.success(Unit) + } catch (e: Exception) { + logger.error("用户修改自己密码异常:username=$currentUsername", e) + Result.failure(e) + } + } + + /** + * 删除用户 + * 注意:此方法只允许默认账户调用,且不能删除默认账户和自己 + */ + @Transactional + fun deleteUser(userId: Long, currentUsername: String): Result { + return try { + // 验证当前用户是否为默认账户(双重验证,确保安全) + val currentUser = userRepository.findByUsername(currentUsername) + ?: return Result.failure(IllegalArgumentException("当前用户不存在")) + + if (!currentUser.isDefault) { + logger.warn("非默认账户尝试删除用户:currentUser=$currentUsername") + return Result.failure(IllegalStateException("只有默认账户可以删除用户")) + } + + // 查找目标用户 + val targetUser = userRepository.findById(userId).orElse(null) + ?: return Result.failure(IllegalArgumentException("用户不存在")) + + // 不能删除默认账户 + if (targetUser.isDefault) { + return Result.failure(IllegalArgumentException("不能删除默认账户")) + } + + // 不能删除自己(通过JWT中的用户名验证,不可篡改) + if (targetUser.username == currentUsername) { + return Result.failure(IllegalArgumentException("不能删除自己")) + } + + // 删除用户 + userRepository.delete(targetUser) + + logger.info("删除用户成功:userId=$userId, username=${targetUser.username}, deletedBy=$currentUsername") + Result.success(Unit) + } catch (e: Exception) { + logger.error("删除用户异常:userId=$userId, currentUser=$currentUsername", e) + Result.failure(e) + } + } +} + diff --git a/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JwtUtils.kt b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JwtUtils.kt new file mode 100644 index 0000000..0d6da5e --- /dev/null +++ b/backend/src/main/kotlin/com/wrbug/polymarketbot/util/JwtUtils.kt @@ -0,0 +1,132 @@ +package com.wrbug.polymarketbot.util + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.util.* +import javax.crypto.SecretKey + +/** + * JWT工具类 + */ +@Component +class JwtUtils { + + @Value("\${jwt.secret}") + private lateinit var secret: String + + @Value("\${jwt.expiration}") + private var expiration: Long = 604800000 // 7天,默认值 + + @Value("\${jwt.refresh-threshold}") + private var refreshThreshold: Long = 86400000 // 1天,默认值 + + /** + * 获取签名密钥 + */ + private fun getSigningKey(): SecretKey { + return Keys.hmacShaKeyFor(secret.toByteArray()) + } + + /** + * 生成JWT token + * @param username 用户名 + * @return JWT token字符串 + */ + fun generateToken(username: String): String { + val now = Date() + val expiryDate = Date(now.time + expiration) + + return Jwts.builder() + .subject(username) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact() + } + + /** + * 从token中获取Claims + */ + private fun getClaimsFromToken(token: String): Claims? { + return try { + Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .payload + } catch (e: Exception) { + null + } + } + + /** + * 验证token是否有效 + */ + fun validateToken(token: String): Boolean { + return try { + val claims = getClaimsFromToken(token) + claims != null && !isTokenExpired(token) + } catch (e: Exception) { + false + } + } + + /** + * 从token中获取用户名 + */ + fun getUsernameFromToken(token: String): String? { + return try { + val claims = getClaimsFromToken(token) + claims?.subject + } catch (e: Exception) { + null + } + } + + /** + * 获取token签发时间(毫秒时间戳) + */ + fun getIssuedAtFromToken(token: String): Long? { + return try { + val claims = getClaimsFromToken(token) + claims?.issuedAt?.time + } catch (e: Exception) { + null + } + } + + /** + * 判断token是否已过期 + */ + fun isTokenExpired(token: String): Boolean { + return try { + val claims = getClaimsFromToken(token) + val expiration = claims?.expiration ?: return true + expiration.before(Date()) + } catch (e: Exception) { + true + } + } + + /** + * 判断token是否使用超过1天但未过期(用于自动刷新) + */ + fun isTokenExpiring(token: String): Boolean { + return try { + if (isTokenExpired(token)) { + return false + } + val issuedAt = getIssuedAtFromToken(token) ?: return false + val now = System.currentTimeMillis() + val timeSinceIssued = now - issuedAt + // 如果使用时间超过刷新阈值(1天)但未过期,则需要刷新 + timeSinceIssued >= refreshThreshold + } catch (e: Exception) { + false + } + } +} + diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f0a3679..3a015ad 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -56,3 +56,19 @@ copy.trading.polling.enabled=${COPY_TRADING_POLLING_ENABLED:true} # WebSocket 配置 websocket.heartbeat-timeout=${WEBSOCKET_HEARTBEAT_TIMEOUT:60000} +# JWT配置 +jwt.secret=${JWT_SECRET:your-secret-key-change-in-production} +# 7天(毫秒) +jwt.expiration=604800000 +# 1天(毫秒),超过此时间但未过期时自动刷新 +jwt.refresh-threshold=86400000 + +# 密码重置配置 +admin.reset-password.key=${ADMIN_RESET_PASSWORD_KEY:change-me-in-production} + +# 频率限制配置 +# 1分钟内最多3次 +rate-limit.reset-password.max-attempts=3 +# 时间窗口(秒) +rate-limit.reset-password.window-seconds=60 + diff --git a/backend/src/main/resources/db/migration/V11__create_users_table.sql b/backend/src/main/resources/db/migration/V11__create_users_table.sql new file mode 100644 index 0000000..036b101 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__create_users_table.sql @@ -0,0 +1,10 @@ +-- 创建用户表(用于JWT登录鉴权) +CREATE TABLE IF NOT EXISTS users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(唯一)', + password VARCHAR(255) NOT NULL COMMENT '密码(BCrypt加密)', + created_at BIGINT NOT NULL COMMENT '创建时间(毫秒时间戳)', + updated_at BIGINT NOT NULL COMMENT '更新时间(毫秒时间戳)', + INDEX idx_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表(JWT登录鉴权)'; + diff --git a/backend/src/main/resources/db/migration/V12__add_is_default_to_users.sql b/backend/src/main/resources/db/migration/V12__add_is_default_to_users.sql new file mode 100644 index 0000000..8999f5c --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_is_default_to_users.sql @@ -0,0 +1,8 @@ +-- 添加 is_default 字段到 users 表 +ALTER TABLE users +ADD COLUMN is_default BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否默认账户(首次创建的用户)'; + +-- 为默认账户添加索引 +ALTER TABLE users +ADD INDEX idx_is_default (is_default); + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 21bf6f8..313d360 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,12 @@ -import { useEffect, useCallback } from 'react' -import { BrowserRouter, Routes, Route } from 'react-router-dom' -import { ConfigProvider, notification } from 'antd' +import { useEffect, useCallback, useState } from 'react' +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom' +import { ConfigProvider, notification, Spin } from 'antd' import zhCN from 'antd/locale/zh_CN' import Layout from './components/Layout' +import Login from './pages/Login' +import ResetPassword from './pages/ResetPassword' import AccountList from './pages/AccountList' +import UserList from './pages/UserList' import AccountImport from './pages/AccountImport' import AccountDetail from './pages/AccountDetail' import AccountEdit from './pages/AccountEdit' @@ -24,8 +27,30 @@ import CopyTradingSellOrders from './pages/CopyTradingSellOrders' import CopyTradingMatchedOrders from './pages/CopyTradingMatchedOrders' import { wsManager } from './services/websocket' import type { OrderPushMessage } from './types' +import { apiService } from './services/api' +import { hasToken } from './utils' + +/** + * 路由保护组件 + */ +const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const location = useLocation() + const isAuthPage = location.pathname === '/login' || location.pathname === '/reset-password' + + if (isAuthPage) { + return <>{children} + } + + if (!hasToken()) { + return + } + + return {children} +} function App() { + const [isFirstUse, setIsFirstUse] = useState(null) + const [checking, setChecking] = useState(true) /** * 获取订单类型文本 */ @@ -100,17 +125,36 @@ function App() { }) }, [getOrderTypeText]) - // 应用启动时立即建立全局 WebSocket 连接 + // 应用启动时检查是否首次使用 useEffect(() => { - // 立即建立连接(如果还未连接) - if (!wsManager.isConnected()) { - wsManager.connect() + const checkFirstUse = async () => { + try { + const response = await apiService.auth.checkFirstUse() + if (response.data.code === 0 && response.data.data) { + setIsFirstUse(response.data.data.isFirstUse) + } + } catch (error) { + console.error('检查首次使用失败:', error) + setIsFirstUse(false) // 出错时默认不是首次使用 + } finally { + setChecking(false) + } } - // 注意:应用不会卸载,所以不需要在 cleanup 中断开连接 - // WebSocket 连接会在整个应用生命周期中保持,并自动重连 + checkFirstUse() }, []) + // 应用启动时立即建立全局 WebSocket 连接(仅在已登录时) + useEffect(() => { + // 只有在已登录且不是首次使用的情况下才建立WebSocket连接 + if (!checking && isFirstUse === false && hasToken() && !wsManager.isConnected()) { + wsManager.connect() + } else if (!hasToken() && wsManager.isConnected()) { + // 如果未登录但WebSocket已连接,断开连接 + wsManager.disconnect() + } + }, [checking, isFirstUse]) + // 订阅订单推送并显示全局通知 useEffect(() => { const unsubscribe = wsManager.subscribe('order', (data: OrderPushMessage) => { @@ -122,33 +166,70 @@ function App() { } }, [handleOrderPush]) + // 如果正在检查首次使用,显示加载中 + if (checking) { + return ( + +
+ +
+
+ ) + } + + // 如果首次使用,直接跳转到重置密码页面 + if (isFirstUse === true) { + return ( + + + + } /> + } /> + + + + ) + } + return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + {/* 公开路由(不需要鉴权) */} + } /> + } /> + + {/* 受保护的路由 */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* 默认重定向到登录页 */} + } /> + ) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 2e00ace..27ff2ef 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { Layout as AntLayout, Menu, Drawer, Button } from 'antd' +import { Layout as AntLayout, Menu, Drawer, Button, Modal } from 'antd' import { useMediaQuery } from 'react-responsive' import { WalletOutlined, @@ -10,10 +10,14 @@ import { MenuOutlined, FileTextOutlined, LinkOutlined, - AppstoreOutlined + AppstoreOutlined, + TeamOutlined, + LogoutOutlined } from '@ant-design/icons' import type { MenuProps } from 'antd' import type { ReactNode } from 'react' +import { removeToken } from '../utils' +import { wsManager } from '../services/websocket' const { Header, Content, Sider } = AntLayout @@ -35,10 +39,11 @@ const Layout: React.FC = ({ children }) => { // 获取当前应该打开的父菜单 const getInitialOpenKeys = (): string[] => { const path = location.pathname + const keys: string[] = [] if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) { - return ['/copy-trading-management'] + keys.push('/copy-trading-management') } - return [] + return keys } const [openKeys, setOpenKeys] = useState(getInitialOpenKeys()) @@ -46,9 +51,11 @@ const Layout: React.FC = ({ children }) => { // 当路径变化时,自动打开对应的父菜单 useEffect(() => { const path = location.pathname + const keys: string[] = [] if (path.startsWith('/leaders') || path.startsWith('/templates') || path.startsWith('/copy-trading')) { - setOpenKeys(['/copy-trading-management']) + keys.push('/copy-trading-management') } + setOpenKeys(keys) }, [location.pathname]) const menuItems: MenuProps['items'] = [ @@ -88,14 +95,53 @@ const Layout: React.FC = ({ children }) => { key: '/statistics', icon: , label: '统计信息' + }, + { + key: '/users', + icon: , + label: '用户管理' + }, + { + key: 'logout', + icon: , + label: '退出登录' } ] + const handleLogout = () => { + removeToken() + // 断开 WebSocket 连接 + wsManager.disconnect() + navigate('/login', { replace: true }) + } + + const handleLogoutConfirm = () => { + Modal.confirm({ + title: '确认退出', + content: '确定要退出登录吗?', + okText: '确定', + cancelText: '取消', + onOk: () => { + handleLogout() + if (isMobile) { + setMobileMenuOpen(false) + } + } + }) + } + const handleMenuClick = ({ key }: { key: string }) => { // 如果是父菜单,不导航 if (key === '/copy-trading-management') { return } + + // 处理退出登录 + if (key === 'logout') { + handleLogoutConfirm() + return + } + navigate(key) if (isMobile) { setMobileMenuOpen(false) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..beb6f75 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Form, Input, Button, message, Typography } from 'antd' +import { UserOutlined, LockOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import { setToken } from '../utils' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography + +const Login: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + const [form] = Form.useForm() + + const handleLogin = async (values: { username: string; password: string }) => { + setLoading(true) + try { + const response = await apiService.auth.login(values) + if (response.data.code === 0 && response.data.data) { + const token = response.data.data.token + setToken(token) + message.success('登录成功') + // 跳转到首页 + navigate('/') + } else { + message.error(response.data.msg || '登录失败') + } + } catch (error: any) { + console.error('登录失败:', error) + const errorMsg = error.response?.data?.msg || error.message || '登录失败' + message.error(errorMsg) + } finally { + setLoading(false) + } + } + + return ( +
+ + + 登录 + +
+ + } + placeholder="用户名" + autoComplete="username" + /> + + + } + placeholder="密码" + autoComplete="current-password" + /> + + + + +
+
+
+ ) +} + +export default Login + diff --git a/frontend/src/pages/ResetPassword.tsx b/frontend/src/pages/ResetPassword.tsx new file mode 100644 index 0000000..fc72b3e --- /dev/null +++ b/frontend/src/pages/ResetPassword.tsx @@ -0,0 +1,230 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Card, Form, Input, Button, message, Typography, Alert, Progress } from 'antd' +import { LockOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography + +/** + * 计算密码强度 + * @param password 密码 + * @returns 强度等级 0-4 (0: 弱, 1: 较弱, 2: 中等, 3: 强, 4: 很强) + */ +const getPasswordStrength = (password: string): number => { + if (!password) return 0 + if (password.length < 6) return 0 + + let strength = 0 + // 长度加分 + if (password.length >= 6) strength += 1 + if (password.length >= 8) strength += 1 + if (password.length >= 12) strength += 1 + + // 字符类型加分 + if (/[a-z]/.test(password)) strength += 0.5 + if (/[A-Z]/.test(password)) strength += 0.5 + if (/\d/.test(password)) strength += 0.5 + if (/[^a-zA-Z0-9]/.test(password)) strength += 0.5 + + return Math.min(4, Math.floor(strength)) +} + +/** + * 获取密码强度文本和颜色 + */ +const getPasswordStrengthInfo = (strength: number): { text: string; color: string; percent: number } => { + switch (strength) { + case 0: + return { text: '弱', color: '#ff4d4f', percent: 25 } + case 1: + return { text: '较弱', color: '#ff7a45', percent: 50 } + case 2: + return { text: '中等', color: '#faad14', percent: 75 } + case 3: + return { text: '强', color: '#52c41a', percent: 100 } + case 4: + return { text: '很强', color: '#52c41a', percent: 100 } + default: + return { text: '弱', color: '#ff4d4f', percent: 0 } + } +} + +const ResetPassword: React.FC = () => { + const navigate = useNavigate() + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [loading, setLoading] = useState(false) + const [passwordStrength, setPasswordStrength] = useState(0) + const [form] = Form.useForm() + + const handleReset = async (values: { + resetKey: string + username: string + newPassword: string + confirmPassword: string + }) => { + if (values.newPassword !== values.confirmPassword) { + message.error('两次输入的密码不一致') + return + } + + setLoading(true) + try { + const response = await apiService.auth.resetPassword({ + resetKey: values.resetKey, + username: values.username, + newPassword: values.newPassword + }) + if (response.data.code === 0) { + message.success('密码重置成功,请登录') + // 延迟跳转到登录页,让用户看到成功提示 + setTimeout(() => { + navigate('/login', { replace: true }) + }, 1000) + } else { + message.error(response.data.msg || '密码重置失败') + } + } catch (error: any) { + console.error('密码重置失败:', error) + const errorMsg = error.response?.data?.msg || error.message || '密码重置失败' + message.error(errorMsg) + } finally { + setLoading(false) + } + } + + return ( +
+ + + 重置密码 + + +
+ + } + placeholder="请输入重置密钥" + /> + + + } + placeholder="请输入用户名" + /> + + + } + placeholder="至少6位" + onChange={(e) => { + const strength = getPasswordStrength(e.target.value) + setPasswordStrength(strength) + }} + /> + + {passwordStrength > 0 && ( + +
+
+ 密码强度: + + {getPasswordStrengthInfo(passwordStrength).text} + +
+ +
+
+ )} + ({ + validator(_, value) { + if (!value || getFieldValue('newPassword') === value) { + return Promise.resolve() + } + return Promise.reject(new Error('两次输入的密码不一致')) + } + }) + ]} + > + } + placeholder="请再次输入密码" + /> + + + + +
+
+
+ ) +} + +export default ResetPassword + diff --git a/frontend/src/pages/UserList.tsx b/frontend/src/pages/UserList.tsx new file mode 100644 index 0000000..34e4e7c --- /dev/null +++ b/frontend/src/pages/UserList.tsx @@ -0,0 +1,366 @@ +import { useEffect, useState } from 'react' +import { Card, Table, Button, Space, Tag, Popconfirm, message, Typography, Modal, Form, Input } from 'antd' +import { PlusOutlined, ReloadOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons' +import { apiService } from '../services/api' +import { useMediaQuery } from 'react-responsive' + +const { Title } = Typography + +interface User { + id: number + username: string + isDefault: boolean + createdAt: number + updatedAt: number +} + +const UserList: React.FC = () => { + const isMobile = useMediaQuery({ maxWidth: 768 }) + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + const [createModalVisible, setCreateModalVisible] = useState(false) + const [updatePasswordModalVisible, setUpdatePasswordModalVisible] = useState(false) + const [updateOwnPasswordModalVisible, setUpdateOwnPasswordModalVisible] = useState(false) + const [selectedUser, setSelectedUser] = useState(null) + const [createForm] = Form.useForm() + const [updatePasswordForm] = Form.useForm() + const [updateOwnPasswordForm] = Form.useForm() + + // 获取当前用户(判断是否是默认账户) + const currentUser = users.find(user => user.isDefault) || users[0] + const isDefaultUser = currentUser?.isDefault || false + + const fetchUsers = async () => { + setLoading(true) + try { + const response = await apiService.users.list() + if (response.data.code === 0 && response.data.data) { + setUsers(response.data.data) + } else { + message.error(response.data.msg || '获取用户列表失败') + } + } catch (error: any) { + console.error('获取用户列表失败:', error) + const errorMsg = error.response?.data?.msg || error.message || '获取用户列表失败' + message.error(errorMsg) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchUsers() + }, []) + + const handleCreate = async (values: { username: string; password: string }) => { + try { + const response = await apiService.users.create({ + username: values.username, + password: values.password + }) + if (response.data.code === 0) { + message.success('创建用户成功') + setCreateModalVisible(false) + createForm.resetFields() + fetchUsers() + } else { + message.error(response.data.msg || '创建用户失败') + } + } catch (error: any) { + console.error('创建用户失败:', error) + const errorMsg = error.response?.data?.msg || error.message || '创建用户失败' + message.error(errorMsg) + } + } + + const handleUpdatePassword = async (values: { newPassword: string }) => { + if (!selectedUser) return + + try { + const response = await apiService.users.updatePassword({ + userId: selectedUser.id, + newPassword: values.newPassword + }) + if (response.data.code === 0) { + message.success('更新密码成功') + setUpdatePasswordModalVisible(false) + setSelectedUser(null) + updatePasswordForm.resetFields() + fetchUsers() + } else { + message.error(response.data.msg || '更新密码失败') + } + } catch (error: any) { + console.error('更新密码失败:', error) + const errorMsg = error.response?.data?.msg || error.message || '更新密码失败' + message.error(errorMsg) + } + } + + const handleUpdateOwnPassword = async (values: { newPassword: string }) => { + try { + const response = await apiService.users.updateOwnPassword({ + newPassword: values.newPassword + }) + if (response.data.code === 0) { + message.success('修改密码成功,请重新登录') + setUpdateOwnPasswordModalVisible(false) + updateOwnPasswordForm.resetFields() + // 延迟跳转到登录页 + setTimeout(() => { + window.location.href = '/login' + }, 1000) + } else { + message.error(response.data.msg || '修改密码失败') + } + } catch (error: any) { + console.error('修改密码失败:', error) + const errorMsg = error.response?.data?.msg || error.message || '修改密码失败' + message.error(errorMsg) + } + } + + const handleDelete = async (user: User) => { + try { + const response = await apiService.users.delete({ userId: user.id }) + if (response.data.code === 0) { + message.success('删除用户成功') + fetchUsers() + } else { + message.error(response.data.msg || '删除用户失败') + } + } catch (error: any) { + console.error('删除用户失败:', error) + const errorMsg = error.response?.data?.msg || error.message || '删除用户失败' + message.error(errorMsg) + } + } + + const columns = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80 + }, + { + title: '用户名', + dataIndex: 'username', + key: 'username' + }, + { + title: '角色', + dataIndex: 'isDefault', + key: 'isDefault', + width: 100, + render: (isDefault: boolean) => ( + + {isDefault ? '默认账户' : '普通用户'} + + ) + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + render: (timestamp: number) => new Date(timestamp).toLocaleString('zh-CN') + }, + { + title: '操作', + key: 'action', + width: 200, + render: (_: any, record: User) => { + // 如果是默认账户,可以管理所有用户 + if (isDefaultUser) { + return ( + + {!record.isDefault && ( + <> + + handleDelete(record)} + okText="确定" + cancelText="取消" + > + + + + )} + + ) + } else { + // 非默认账户:只能看到自己的信息,不显示操作按钮(修改密码通过顶部按钮) + return null + } + } + } + ] + + return ( +
+ +
+ 用户管理 + + + + {isDefaultUser && ( + + )} + +
+ `共 ${total} 条` + }} + scroll={isMobile ? { x: 600 } : undefined} + /> + + + {/* 创建用户弹窗 */} + { + setCreateModalVisible(false) + createForm.resetFields() + }} + onOk={() => createForm.submit()} + okText="创建" + cancelText="取消" + > +
+ + + + + + + +
+ + {/* 修改密码弹窗(管理员修改其他用户密码) */} + { + setUpdatePasswordModalVisible(false) + setSelectedUser(null) + updatePasswordForm.resetFields() + }} + onOk={() => updatePasswordForm.submit()} + okText="确定" + cancelText="取消" + > +
+ + + + +
+ + {/* 修改我的密码弹窗(默认账户修改自己密码) */} + { + setUpdateOwnPasswordModalVisible(false) + updateOwnPasswordForm.resetFields() + }} + onOk={() => updateOwnPasswordForm.submit()} + okText="确定" + cancelText="取消" + > +
+ + + + +
+ + ) +} + +export default UserList + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6a5f5c4..9971ec7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,7 @@ -import axios, { AxiosInstance } from 'axios' +import axios, { AxiosInstance, AxiosError } from 'axios' import type { ApiResponse } from '../types' +import { getToken, setToken, removeToken } from '../utils' +import { wsManager } from './websocket' /** * API 基础配置 @@ -17,6 +19,11 @@ const apiClient: AxiosInstance = axios.create({ */ apiClient.interceptors.request.use( (config) => { + // 从 localStorage 读取 token 并添加到请求头 + const token = getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } return config }, (error) => { @@ -29,11 +36,31 @@ apiClient.interceptors.request.use( */ apiClient.interceptors.response.use( (response) => { + // 检查响应头中是否有新的 token(自动刷新) + const newToken = response.headers['x-new-token'] + if (newToken) { + setToken(newToken) + } return response }, - (error) => { + (error: AxiosError>) => { if (error.response) { - console.error('API 错误:', error.response.data) + const response = error.response + const data = response.data + + // 检查是否是认证错误(2001-2999) + if (data && data.code >= 2001 && data.code < 3000) { + // 清除 token + removeToken() + // 断开 WebSocket 连接 + wsManager.disconnect() + // 跳转到登录页(避免循环跳转) + if (window.location.pathname !== '/login' && window.location.pathname !== '/reset-password') { + window.location.href = '/login' + } + } + + console.error('API 错误:', data) } else if (error.request) { console.error('网络错误:', error.request) } else { @@ -47,6 +74,64 @@ apiClient.interceptors.response.use( * API 服务 */ export const apiService = { + /** + * 用户管理 API + */ + users: { + /** + * 获取用户列表 + */ + list: () => + apiClient.post>('/users/list', {}), + + /** + * 创建用户 + */ + create: (data: { username: string; password: string }) => + apiClient.post>('/users/create', data), + + /** + * 更新用户密码 + */ + updatePassword: (data: { userId: number; newPassword: string }) => + apiClient.post>('/users/update-password', data), + + /** + * 删除用户 + */ + delete: (data: { userId: number }) => + apiClient.post>('/users/delete', data), + + /** + * 用户修改自己的密码 + */ + updateOwnPassword: (data: { newPassword: string }) => + apiClient.post>('/users/update-own-password', data) + }, + + /** + * 认证 API + */ + auth: { + /** + * 登录 + */ + login: (data: { username: string; password: string }) => + apiClient.post>('/auth/login', data), + + /** + * 重置密码 + */ + resetPassword: (data: { resetKey: string; username: string; newPassword: string }) => + apiClient.post>('/auth/reset-password', data), + + /** + * 检查是否首次使用 + */ + checkFirstUse: () => + apiClient.post>('/auth/check-first-use', {}) + }, + /** * 账户管理 API */ diff --git a/frontend/src/services/websocket.ts b/frontend/src/services/websocket.ts index c6004ef..80fb520 100644 --- a/frontend/src/services/websocket.ts +++ b/frontend/src/services/websocket.ts @@ -53,6 +53,13 @@ class WebSocketManager { * 连接 WebSocket(全局共享连接) */ connect(): void { + // 检查是否有token,未登录不允许连接 + const token = this.getToken() + if (!token) { + console.log('[WebSocket] 未登录,不建立连接') + return + } + // 如果已经连接或正在连接,直接返回 if (this.ws?.readyState === WebSocket.OPEN || this.isConnecting) { return @@ -104,8 +111,8 @@ class WebSocketManager { this.isConnecting = false this.notifyConnectionStatus(false) this.stopPing() - // 自动重连(除非正在卸载) - if (!this.isUnmounting) { + // 自动重连(除非正在卸载或未登录) + if (!this.isUnmounting && this.getToken()) { this.scheduleReconnect() } } @@ -113,8 +120,8 @@ class WebSocketManager { console.error('[WebSocket] 创建连接失败:', error) this.isConnecting = false this.notifyConnectionStatus(false) - // 自动重连(除非正在卸载) - if (!this.isUnmounting) { + // 自动重连(除非正在卸载或未登录) + if (!this.isUnmounting && this.getToken()) { this.scheduleReconnect() } } @@ -272,6 +279,11 @@ class WebSocketManager { return } + // 检查是否有token,未登录不重连 + if (!this.getToken()) { + return + } + if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) } @@ -312,14 +324,26 @@ class WebSocketManager { } /** - * 获取 WebSocket URL + * 获取 WebSocket URL(带token认证) */ private getWebSocketUrl(): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host + const token = this.getToken() + if (token) { + // 通过查询参数传递token + return `${protocol}//${host}/ws?token=${encodeURIComponent(token)}` + } return `${protocol}//${host}/ws` } + /** + * 获取token(从localStorage) + */ + private getToken(): string | null { + return localStorage.getItem('jwt_token') + } + /** * 注册连接状态回调 */ diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts new file mode 100644 index 0000000..bcc68ef --- /dev/null +++ b/frontend/src/utils/auth.ts @@ -0,0 +1,34 @@ +/** + * Token 管理工具 + */ + +const TOKEN_KEY = 'jwt_token' + +/** + * 获取 token + */ +export const getToken = (): string | null => { + return localStorage.getItem(TOKEN_KEY) +} + +/** + * 保存 token + */ +export const setToken = (token: string): void => { + localStorage.setItem(TOKEN_KEY, token) +} + +/** + * 清除 token + */ +export const removeToken = (): void => { + localStorage.removeItem(TOKEN_KEY) +} + +/** + * 检查是否有 token + */ +export const hasToken = (): boolean => { + return getToken() !== null +} + diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index f2bd701..0c3ea96 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -37,3 +37,11 @@ export { isValidPrivateKey } from './ethers' +// 统一导出 auth 相关工具函数 +export { + getToken, + setToken, + removeToken, + hasToken +} from './auth' +