feat: 添加JWT登录鉴权和用户管理功能
- 后端功能: - 实现JWT登录鉴权,token有效期7天,超过1天自动刷新 - 添加用户管理功能,支持创建、删除、修改密码 - 首次创建的用户为默认账户,拥有管理权限 - 实现密码重置功能,支持重置密钥和频率限制(1分钟最多3次) - 所有API接口需要JWT鉴权 - WebSocket连接需要JWT鉴权,绑定用户身份 - 前端功能: - 添加登录页面和密码重置页面 - 添加用户管理页面,默认账户可管理所有用户,普通用户只能查看和修改自己 - 添加退出登录功能,带二次确认 - 未登录时不建立WebSocket连接 - API请求自动携带JWT token,认证失败自动跳转登录页 - 安全特性: - 密码使用BCrypt加密存储 - 重置密码错误信息统一处理,避免信息泄露 - 用户操作严格绑定JWT,防止数据篡改和越权
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
+104
@@ -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<Unit> = ApiResponse.authError(message)
|
||||
val json = objectMapper.writeValueAsString(apiResponse)
|
||||
response.writer.write(json)
|
||||
response.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/**")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Any>
|
||||
): 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("*") // 生产环境应该配置具体的域名
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApiResponse<LoginResponse>> {
|
||||
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<ApiResponse<Unit>> {
|
||||
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<ApiResponse<CheckFirstUseResponse>> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApiResponse<List<UserDto>>> {
|
||||
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<ApiResponse<UserDto>> {
|
||||
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<ApiResponse<Unit>> {
|
||||
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<ApiResponse<Unit>> {
|
||||
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<ApiResponse<Unit>> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.wrbug.polymarketbot.dto
|
||||
|
||||
/**
|
||||
* 登录响应
|
||||
*/
|
||||
data class LoginResponse(
|
||||
val token: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查首次使用响应
|
||||
*/
|
||||
data class CheckFirstUseResponse(
|
||||
val isFirstUse: Boolean
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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, "资源不存在"),
|
||||
|
||||
@@ -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<User, Long> {
|
||||
|
||||
/**
|
||||
* 根据用户名查找用户
|
||||
*/
|
||||
fun findByUsername(username: String): User?
|
||||
|
||||
/**
|
||||
* 检查用户名是否存在
|
||||
*/
|
||||
fun existsByUsername(username: String): Boolean
|
||||
|
||||
/**
|
||||
* 查找默认账户
|
||||
*/
|
||||
fun findByIsDefaultTrue(): User?
|
||||
|
||||
/**
|
||||
* 查找所有用户,按创建时间排序
|
||||
*/
|
||||
fun findAllByOrderByCreatedAtAsc(): List<User>
|
||||
}
|
||||
|
||||
@@ -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<LoginResponse> {
|
||||
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<Unit> {
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MutableList<Long>>(mutableListOf())
|
||||
|
||||
/**
|
||||
* 检查重置密码频率限制(全局限制,不按IP)
|
||||
* @return Result,如果超过限制则返回失败
|
||||
*/
|
||||
fun checkResetPasswordRateLimit(): Result<Unit> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserDto> {
|
||||
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<UserDto> {
|
||||
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<Unit> {
|
||||
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<Unit> {
|
||||
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<Unit> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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登录鉴权)';
|
||||
|
||||
@@ -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);
|
||||
|
||||
+114
-33
@@ -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 <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isFirstUse, setIsFirstUse] = useState<boolean | null>(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 (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 如果首次使用,直接跳转到重置密码页面
|
||||
if (isFirstUse === true) {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="*" element={<Navigate to="/reset-password" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<AccountList />} />
|
||||
<Route path="/accounts" element={<AccountList />} />
|
||||
<Route path="/accounts/import" element={<AccountImport />} />
|
||||
<Route path="/accounts/detail" element={<AccountDetail />} />
|
||||
<Route path="/accounts/edit" element={<AccountEdit />} />
|
||||
<Route path="/leaders" element={<LeaderList />} />
|
||||
<Route path="/leaders/add" element={<LeaderAdd />} />
|
||||
<Route path="/leaders/edit" element={<LeaderEdit />} />
|
||||
<Route path="/templates" element={<TemplateList />} />
|
||||
<Route path="/templates/add" element={<TemplateAdd />} />
|
||||
<Route path="/templates/edit/:id" element={<TemplateEdit />} />
|
||||
<Route path="/copy-trading" element={<CopyTradingList />} />
|
||||
<Route path="/copy-trading/add" element={<CopyTradingAdd />} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<CopyTradingStatistics />} />
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<CopyTradingBuyOrders />} />
|
||||
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<CopyTradingSellOrders />} />
|
||||
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<CopyTradingMatchedOrders />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/positions" element={<PositionList />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
<Routes>
|
||||
{/* 公开路由(不需要鉴权) */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* 受保护的路由 */}
|
||||
<Route path="/" element={<ProtectedRoute><AccountList /></ProtectedRoute>} />
|
||||
<Route path="/accounts" element={<ProtectedRoute><AccountList /></ProtectedRoute>} />
|
||||
<Route path="/accounts/import" element={<ProtectedRoute><AccountImport /></ProtectedRoute>} />
|
||||
<Route path="/accounts/detail" element={<ProtectedRoute><AccountDetail /></ProtectedRoute>} />
|
||||
<Route path="/accounts/edit" element={<ProtectedRoute><AccountEdit /></ProtectedRoute>} />
|
||||
<Route path="/leaders" element={<ProtectedRoute><LeaderList /></ProtectedRoute>} />
|
||||
<Route path="/leaders/add" element={<ProtectedRoute><LeaderAdd /></ProtectedRoute>} />
|
||||
<Route path="/leaders/edit" element={<ProtectedRoute><LeaderEdit /></ProtectedRoute>} />
|
||||
<Route path="/templates" element={<ProtectedRoute><TemplateList /></ProtectedRoute>} />
|
||||
<Route path="/templates/add" element={<ProtectedRoute><TemplateAdd /></ProtectedRoute>} />
|
||||
<Route path="/templates/edit/:id" element={<ProtectedRoute><TemplateEdit /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading" element={<ProtectedRoute><CopyTradingList /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/add" element={<ProtectedRoute><CopyTradingAdd /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/statistics/:copyTradingId" element={<ProtectedRoute><CopyTradingStatistics /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/orders/buy/:copyTradingId" element={<ProtectedRoute><CopyTradingBuyOrders /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/orders/sell/:copyTradingId" element={<ProtectedRoute><CopyTradingSellOrders /></ProtectedRoute>} />
|
||||
<Route path="/copy-trading/orders/matched/:copyTradingId" element={<ProtectedRoute><CopyTradingMatchedOrders /></ProtectedRoute>} />
|
||||
<Route path="/config" element={<ProtectedRoute><ConfigPage /></ProtectedRoute>} />
|
||||
<Route path="/positions" element={<ProtectedRoute><PositionList /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute><Statistics /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute><UserList /></ProtectedRoute>} />
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
)
|
||||
|
||||
@@ -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<LayoutProps> = ({ 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<string[]>(getInitialOpenKeys())
|
||||
@@ -46,9 +51,11 @@ const Layout: React.FC<LayoutProps> = ({ 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<LayoutProps> = ({ children }) => {
|
||||
key: '/statistics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '统计信息'
|
||||
},
|
||||
{
|
||||
key: '/users',
|
||||
icon: <TeamOutlined />,
|
||||
label: '用户管理'
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
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)
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: isMobile ? '20px' : '40px',
|
||||
background: '#f0f2f5'
|
||||
}}>
|
||||
<Card
|
||||
style={{
|
||||
width: isMobile ? '100%' : '400px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Title level={2} style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
登录
|
||||
</Title>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleLogin}
|
||||
layout="vertical"
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: isMobile ? '20px' : '40px',
|
||||
background: '#f0f2f5'
|
||||
}}>
|
||||
<Card
|
||||
style={{
|
||||
width: isMobile ? '100%' : '500px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Title level={2} style={{ textAlign: 'center', marginBottom: '16px' }}>
|
||||
重置密码
|
||||
</Title>
|
||||
<Alert
|
||||
message="首次使用系统"
|
||||
description="请使用管理员提供的重置密钥设置初始密码"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: '24px' }}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleReset}
|
||||
layout="vertical"
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
>
|
||||
<Form.Item
|
||||
name="resetKey"
|
||||
label="重置密钥"
|
||||
rules={[
|
||||
{ required: true, message: '请输入重置密钥' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<KeyOutlined />}
|
||||
placeholder="请输入重置密钥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码至少6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="至少6位"
|
||||
onChange={(e) => {
|
||||
const strength = getPasswordStrength(e.target.value)
|
||||
setPasswordStrength(strength)
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
{passwordStrength > 0 && (
|
||||
<Form.Item>
|
||||
<div style={{ marginTop: '-16px', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>密码强度:</span>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
color: getPasswordStrengthInfo(passwordStrength).color
|
||||
}}>
|
||||
{getPasswordStrengthInfo(passwordStrength).text}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={getPasswordStrengthInfo(passwordStrength).percent}
|
||||
strokeColor={getPasswordStrengthInfo(passwordStrength).color}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="确认密码"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '请确认密码' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致'))
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="请再次输入密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPassword
|
||||
|
||||
@@ -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<User[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false)
|
||||
const [updatePasswordModalVisible, setUpdatePasswordModalVisible] = useState(false)
|
||||
const [updateOwnPasswordModalVisible, setUpdateOwnPasswordModalVisible] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(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) => (
|
||||
<Tag color={isDefault ? 'red' : 'blue'}>
|
||||
{isDefault ? '默认账户' : '普通用户'}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Space size="small">
|
||||
{!record.isDefault && (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedUser(record)
|
||||
setUpdatePasswordModalVisible(true)
|
||||
}}
|
||||
>
|
||||
修改密码
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个用户吗?"
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
} else {
|
||||
// 非默认账户:只能看到自己的信息,不显示操作按钮(修改密码通过顶部按钮)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>用户管理</Title>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setUpdateOwnPasswordModalVisible(true)}
|
||||
>
|
||||
修改我的密码
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchUsers}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{isDefaultUser && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalVisible(true)}
|
||||
>
|
||||
新增用户
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: isMobile ? 10 : 20,
|
||||
showSizeChanger: !isMobile,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
}}
|
||||
scroll={isMobile ? { x: 600 } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 创建用户弹窗 */}
|
||||
<Modal
|
||||
title="新增用户"
|
||||
open={createModalVisible}
|
||||
onCancel={() => {
|
||||
setCreateModalVisible(false)
|
||||
createForm.resetFields()
|
||||
}}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
onFinish={handleCreate}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="用户名"
|
||||
rules={[
|
||||
{ required: true, message: '请输入用户名' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入密码' },
|
||||
{ min: 6, message: '密码至少6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="至少6位" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 修改密码弹窗(管理员修改其他用户密码) */}
|
||||
<Modal
|
||||
title="修改密码"
|
||||
open={updatePasswordModalVisible}
|
||||
onCancel={() => {
|
||||
setUpdatePasswordModalVisible(false)
|
||||
setSelectedUser(null)
|
||||
updatePasswordForm.resetFields()
|
||||
}}
|
||||
onOk={() => updatePasswordForm.submit()}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={updatePasswordForm}
|
||||
onFinish={handleUpdatePassword}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码至少6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="至少6位" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 修改我的密码弹窗(默认账户修改自己密码) */}
|
||||
<Modal
|
||||
title="修改我的密码"
|
||||
open={updateOwnPasswordModalVisible}
|
||||
onCancel={() => {
|
||||
setUpdateOwnPasswordModalVisible(false)
|
||||
updateOwnPasswordForm.resetFields()
|
||||
}}
|
||||
onOk={() => updateOwnPasswordForm.submit()}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={updateOwnPasswordForm}
|
||||
onFinish={handleUpdateOwnPassword}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 6, message: '密码至少6位' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="至少6位" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserList
|
||||
|
||||
@@ -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<ApiResponse<any>>) => {
|
||||
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<ApiResponse<any[]>>('/users/list', {}),
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*/
|
||||
create: (data: { username: string; password: string }) =>
|
||||
apiClient.post<ApiResponse<any>>('/users/create', data),
|
||||
|
||||
/**
|
||||
* 更新用户密码
|
||||
*/
|
||||
updatePassword: (data: { userId: number; newPassword: string }) =>
|
||||
apiClient.post<ApiResponse<void>>('/users/update-password', data),
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*/
|
||||
delete: (data: { userId: number }) =>
|
||||
apiClient.post<ApiResponse<void>>('/users/delete', data),
|
||||
|
||||
/**
|
||||
* 用户修改自己的密码
|
||||
*/
|
||||
updateOwnPassword: (data: { newPassword: string }) =>
|
||||
apiClient.post<ApiResponse<void>>('/users/update-own-password', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 认证 API
|
||||
*/
|
||||
auth: {
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
login: (data: { username: string; password: string }) =>
|
||||
apiClient.post<ApiResponse<{ token: string }>>('/auth/login', data),
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*/
|
||||
resetPassword: (data: { resetKey: string; username: string; newPassword: string }) =>
|
||||
apiClient.post<ApiResponse<void>>('/auth/reset-password', data),
|
||||
|
||||
/**
|
||||
* 检查是否首次使用
|
||||
*/
|
||||
checkFirstUse: () =>
|
||||
apiClient.post<ApiResponse<{ isFirstUse: boolean }>>('/auth/check-first-use', {})
|
||||
},
|
||||
|
||||
/**
|
||||
* 账户管理 API
|
||||
*/
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册连接状态回调
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -37,3 +37,11 @@ export {
|
||||
isValidPrivateKey
|
||||
} from './ethers'
|
||||
|
||||
// 统一导出 auth 相关工具函数
|
||||
export {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
hasToken
|
||||
} from './auth'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user