feat: 解耦认证逻辑

This commit is contained in:
2026-06-24 12:17:09 +08:00
parent 5d9f5b8ab7
commit ab2a2dbf7f
14 changed files with 332 additions and 94 deletions

View File

@@ -0,0 +1,43 @@
package com.webgame.webgamebackend.common.auth;
/**
* 当前请求用户上下文
* <p>
* 基于 ThreadLocal 存储当前请求的用户 ID由 AuthInterceptor 在 preHandle 中设置,
* 在 afterCompletion 中清理。业务代码可通过 {@link #get()} 获取当前用户 ID。
* <p>
* 注意:仅在同一请求线程内有效,异步场景需手动传递。
*/
public final class AuthContext {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
private AuthContext() {
// 工具类,禁止实例化
}
/**
* 设置当前请求的用户 ID
*/
public static void set(String userId) {
USER_ID.set(userId);
}
/**
* 获取当前请求的用户 ID
*
* @return 用户 ID未设置时返回 null
*/
public static String get() {
return USER_ID.get();
}
/**
* 清理当前请求的用户上下文
* <p>
* 必须在请求结束后调用,防止 ThreadLocal 内存泄漏。
*/
public static void clear() {
USER_ID.remove();
}
}

View File

@@ -0,0 +1,129 @@
package com.webgame.webgamebackend.common.auth;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import com.webgame.webgamebackend.common.exception.BusinessException;
import com.webgame.webgamebackend.common.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.List;
/**
* 认证拦截器,替代 Sa-Token 的 SaInterceptor
* <p>
* 职责:
* <ol>
* <li>放行公开路径和 OPTIONS 预检请求</li>
* <li>调用 {@link AuthenticationProvider#getCurrentUserId()} 触发登录校验</li>
* <li>将 Sa-Token 框架异常转换为 {@link BusinessException},实现认证框架与业务解耦</li>
* <li>校验通过后将用户 ID 存入 {@link AuthContext}</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final AuthenticationProvider authProvider;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 公开路由条目
*
* @param path Ant 风格路径模式
* @param methods 允许的 HTTP 方法,空数组表示所有方法
*/
private record PublicRoute(String path, String... methods) {
boolean matches(String requestPath, String requestMethod) {
if (!new AntPathMatcher().match(path, requestPath)) {
return false;
}
if (methods.length == 0) {
return true; // 所有方法放行
}
for (String m : methods) {
if (m.equalsIgnoreCase(requestMethod)) {
return true;
}
}
return false;
}
}
/** 无需登录的公开路由 */
private static final List<PublicRoute> PUBLIC_ROUTES = List.of(
// 框架级路径(所有方法)
new PublicRoute("/actuator/**"),
new PublicRoute("/swagger-ui/**"),
new PublicRoute("/swagger-ui.html"),
new PublicRoute("/v3/api-docs/**"),
new PublicRoute("/webjars/**"),
new PublicRoute("/error"),
new PublicRoute("/sse/**"),
// 账号模块 — 登录/注册/刷新无需认证
new PublicRoute("/account/login", "POST"),
new PublicRoute("/account/register", "POST"),
new PublicRoute("/account/refresh", "POST"),
// 游戏模块 — 查看列表/详情无需认证
new PublicRoute("/game/list", "GET"),
new PublicRoute("/game/room/list", "GET"),
// GET /game/room/{roomId} 详情页,排除 POST/PUT/DELETE 等写操作
new PublicRoute("/game/room/**", "GET")
);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
String method = request.getMethod();
String path = request.getRequestURI();
// OPTIONS 预检请求放行
if ("OPTIONS".equalsIgnoreCase(method)) {
return true;
}
// 公开路由放行
if (isPublicRoute(path, method)) {
return true;
}
// 认证校验:调用 authProvider 触发 Sa-Token 的登录检查
try {
String userId = authProvider.getCurrentUserId();
AuthContext.set(userId);
return true;
} catch (NotLoginException e) {
log.debug("未登录访问受保护路径: path={}", path);
throw new BusinessException(ErrorCode.AUTH_NOT_LOGIN);
} catch (NotPermissionException | NotRoleException e) {
log.warn("权限不足: path={}, message={}", path, e.getMessage());
throw new BusinessException(ErrorCode.AUTH_FORBIDDEN);
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
// 请求结束后清理 ThreadLocal防止内存泄漏
AuthContext.clear();
}
/** 判断路由是否为公开路由(无需登录) */
private boolean isPublicRoute(String path, String method) {
for (PublicRoute route : PUBLIC_ROUTES) {
if (route.matches(path, method)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,54 @@
package com.webgame.webgamebackend.common.auth;
/**
* 认证提供者抽象接口
* <p>
* 隔离具体的认证框架实现(当前为 Sa-Token业务代码只依赖此接口。
* 如需切换认证框架,只需提供新的实现类即可,业务代码无需修改。
*/
public interface AuthenticationProvider {
/**
* 用户登录,使 userId 在当前会话中保持登录态
*
* @param userId 用户唯一标识
* @return 生成的 Access Token
*/
String login(Object userId);
/**
* 当前会话登出,使 Access Token 失效
*/
void logout();
/**
* 获取当前已登录的用户 ID
*
* @return 当前用户 ID
* @throws cn.dev33.satoken.exception.NotLoginException 未登录时由实现层抛出
*/
String getCurrentUserId();
/**
* 通过 Token 获取用户 ID不检查当前会话登录态
*
* @param token Access Token
* @return 用户 IDToken 无效时返回 null
*/
String getUserIdByToken(String token);
/**
* 获取当前会话的 Access Token
*
* @return Access Token 字符串
*/
String getCurrentToken();
/**
* 在当前请求上下文中设置 Token使后续 getCurrentUserId() 可用
*
* @param token Access Token
* @param timeout Token 有效期(秒),-1 表示保持原有有效期
*/
void setContextToken(String token, int timeout);
}

View File

@@ -0,0 +1,50 @@
package com.webgame.webgamebackend.common.auth;
import cn.dev33.satoken.stp.StpUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* Sa-Token 认证提供者实现
* <p>
* 将 Sa-Token 的 StpUtil 静态方法封装为 AuthenticationProvider 接口,
* 是项目中唯一直接依赖 Sa-Token 的业务代码之外的文件。
*/
@Slf4j
@Component
public class SaTokenAuthenticationProvider implements AuthenticationProvider {
@Override
public String login(Object userId) {
StpUtil.login(userId);
String token = StpUtil.getTokenValue();
log.debug("用户登录成功: userId={}", userId);
return token;
}
@Override
public void logout() {
StpUtil.logout();
}
@Override
public String getCurrentUserId() {
return StpUtil.getLoginIdAsString();
}
@Override
public String getUserIdByToken(String token) {
Object loginId = StpUtil.getLoginIdByToken(token);
return loginId != null ? loginId.toString() : null;
}
@Override
public String getCurrentToken() {
return StpUtil.getTokenValue();
}
@Override
public void setContextToken(String token, int timeout) {
StpUtil.setTokenValue(token, timeout);
}
}

View File

@@ -1,10 +1,10 @@
package com.webgame.webgamebackend.common.config; package com.webgame.webgamebackend.common.config;
import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction; import cn.dev33.satoken.fun.strategy.SaCorsHandleFunction;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod; import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter; import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil; import com.webgame.webgamebackend.common.auth.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -12,28 +12,22 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** /**
* Sa-Token 鉴权配置 * Web 鉴权配置
* <p>
* 使用自定义 {@link AuthInterceptor} 替代 Sa-Token 的 {@code SaInterceptor}
* 在拦截器层将 Sa-Token 框架异常转为业务异常,实现认证框架与业务代码解耦。
*/ */
@Slf4j @Slf4j
@Configuration @Configuration
@RequiredArgsConstructor
public class SaTokenConfigure implements WebMvcConfigurer { public class SaTokenConfigure implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> { registry.addInterceptor(authInterceptor)
SaRouter.match(SaHttpMethod.OPTIONS).free(r -> { .addPathPatterns("/**")
}).back();
SaRouter.match("/**")
.notMatch("/actuator/**")
.notMatch("/swagger-ui/**")
.notMatch("/swagger-ui.html")
.notMatch("/v3/api-docs/**")
.notMatch("/webjars/**")
.notMatch("/error")
.notMatch("/sse/**")
.check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**")
.excludePathPatterns("/error"); .excludePathPatterns("/error");
} }

View File

@@ -28,6 +28,10 @@ public enum ErrorCode {
AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF", 400), AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF", 400),
AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB", 400), AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB", 400),
// ========== 认证模块 ==========
AUTH_NOT_LOGIN(4001, "请先登录", 401),
AUTH_FORBIDDEN(4002, "权限不足", 403),
// ========== 游戏模块 ========== // ========== 游戏模块 ==========
GAME_NOT_FOUND(3001, "游戏不存在或已下架", 404), GAME_NOT_FOUND(3001, "游戏不存在或已下架", 404),
ROOM_NOT_FOUND(3002, "房间不存在", 404), ROOM_NOT_FOUND(3002, "房间不存在", 404),

View File

@@ -1,9 +1,5 @@
package com.webgame.webgamebackend.common.handler; package com.webgame.webgamebackend.common.handler;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;
import cn.dev33.satoken.exception.SaTokenContextException;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.webgame.webgamebackend.common.dto.ApiResponse; import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.BusinessException;
@@ -32,6 +28,9 @@ import java.io.IOException;
* <p> * <p>
* 业务异常(仅来自 REST JSON 请求)返回 ResponseEntity经过 Spring 内容协商。 * 业务异常(仅来自 REST JSON 请求)返回 ResponseEntity经过 Spring 内容协商。
* 框架级异常(可能来自 SSE/浏览器等任意请求类型)直接写 HttpServletResponse 绕过内容协商。 * 框架级异常(可能来自 SSE/浏览器等任意请求类型)直接写 HttpServletResponse 绕过内容协商。
* <p>
* 认证相关异常NotLoginException 等)已由 {@code AuthInterceptor} 转为
* {@link BusinessException},此处不再直接依赖 Sa-Token。
*/ */
@Slf4j @Slf4j
@RestControllerAdvice @RestControllerAdvice
@@ -44,12 +43,6 @@ public class GlobalExceptionHandler {
// SSE 超时在长连接场景下属于正常情况,不写响应体 // SSE 超时在长连接场景下属于正常情况,不写响应体
} }
@ExceptionHandler(SaTokenContextException.class)
public void handleSaTokenContext(SaTokenContextException ex) {
log.warn("Sa-Token 上下文不可用: {}", ex.getMessage());
// 长连接场景,不写响应体
}
@ExceptionHandler(AsyncRequestNotUsableException.class) @ExceptionHandler(AsyncRequestNotUsableException.class)
public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) { public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) {
// SSE 响应已被客户端关闭,不写响应体 // SSE 响应已被客户端关闭,不写响应体
@@ -105,28 +98,7 @@ public class GlobalExceptionHandler {
.body(ApiResponse.fail(ErrorCode.AVATAR_TOO_LARGE.getCode(), "文件大小不能超过 2MB")); .body(ApiResponse.fail(ErrorCode.AVATAR_TOO_LARGE.getCode(), "文件大小不能超过 2MB"));
} }
// ==================== 认证/授权异常 → HTTP 401 / 403 ====================
@ExceptionHandler(NotLoginException.class)
public ResponseEntity<ApiResponse<Void>> handleNotLogin(NotLoginException ex) {
log.warn("未登录: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.fail(401, "请先登录"));
}
@ExceptionHandler({NotPermissionException.class, NotRoleException.class})
public ResponseEntity<ApiResponse<Void>> handleForbidden(Exception ex) {
log.warn("权限不足: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.fail(403, "权限不足"));
}
// ==================== 框架级异常(直接写响应,绕过内容协商) ==================== // ==================== 框架级异常(直接写响应,绕过内容协商) ====================
// 这类异常可能来自任意请求类型SSE Accept: text/event-stream、
// 浏览器 Accept: text/html 等),若走 ResponseEntity 会因 Accept 头
// 与 JSON 响应体不匹配而抛出 HttpMediaTypeNotAcceptableException。
@ExceptionHandler(NoResourceFoundException.class) @ExceptionHandler(NoResourceFoundException.class)
public void handleNoResourceFound(NoResourceFoundException ex, HttpServletResponse response) throws IOException { public void handleNoResourceFound(NoResourceFoundException ex, HttpServletResponse response) throws IOException {

View File

@@ -1,6 +1,5 @@
package com.webgame.webgamebackend.controller; package com.webgame.webgamebackend.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import com.webgame.webgamebackend.common.dto.ApiResponse; import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.account.LoginRequest; import com.webgame.webgamebackend.common.dto.account.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse; import com.webgame.webgamebackend.common.dto.account.LoginResponse;
@@ -30,7 +29,6 @@ public class AccountController {
/** /**
* 登录 * 登录
*/ */
@SaIgnore
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) { public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
LoginResponse result = accountService.login(request); LoginResponse result = accountService.login(request);
@@ -40,7 +38,6 @@ public class AccountController {
/** /**
* 注册 * 注册
*/ */
@SaIgnore
@PostMapping("/register") @PostMapping("/register")
public ApiResponse<LoginResponse> register(@Valid @RequestBody RegisterRequest request) { public ApiResponse<LoginResponse> register(@Valid @RequestBody RegisterRequest request) {
LoginResponse result = accountService.register(request); LoginResponse result = accountService.register(request);
@@ -50,7 +47,6 @@ public class AccountController {
/** /**
* 刷新令牌,用 refresh token 换取新的 access token 和 refresh token * 刷新令牌,用 refresh token 换取新的 access token 和 refresh token
*/ */
@SaIgnore
@PostMapping("/refresh") @PostMapping("/refresh")
public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) { public ApiResponse<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
LoginResponse result = accountService.refresh(request.refreshToken()); LoginResponse result = accountService.refresh(request.refreshToken());

View File

@@ -1,7 +1,6 @@
package com.webgame.webgamebackend.controller; package com.webgame.webgamebackend.controller;
import cn.dev33.satoken.annotation.SaIgnore; import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import cn.dev33.satoken.stp.StpUtil;
import com.webgame.webgamebackend.common.dto.ApiResponse; import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.game.*; import com.webgame.webgamebackend.common.dto.game.*;
import com.webgame.webgamebackend.service.GameService; import com.webgame.webgamebackend.service.GameService;
@@ -22,13 +21,13 @@ import org.springframework.web.bind.annotation.*;
public class GameController { public class GameController {
private final GameService gameService; private final GameService gameService;
private final AuthenticationProvider authProvider;
/** /**
* 获取游戏列表 * 获取游戏列表
* *
* 无需登录即可调用。 * 无需登录即可调用。
*/ */
@SaIgnore
@GetMapping("/list") @GetMapping("/list")
public ApiResponse<GameListResponse> list() { public ApiResponse<GameListResponse> list() {
GameListResponse result = gameService.getGameList(); GameListResponse result = gameService.getGameList();
@@ -43,7 +42,6 @@ public class GameController {
* @param gameId 游戏 ID必填 * @param gameId 游戏 ID必填
* @param status 状态筛选(可选) * @param status 状态筛选(可选)
*/ */
@SaIgnore
@GetMapping("/room/list") @GetMapping("/room/list")
public ApiResponse<RoomListResponse> roomList( public ApiResponse<RoomListResponse> roomList(
@RequestParam String gameId, @RequestParam String gameId,
@@ -59,7 +57,7 @@ public class GameController {
*/ */
@PostMapping("/room/create") @PostMapping("/room/create")
public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) { public ApiResponse<CreateRoomResponse> createRoom(@Valid @RequestBody CreateRoomRequest request) {
String userId = StpUtil.getLoginIdAsString(); String userId = authProvider.getCurrentUserId();
CreateRoomResponse result = gameService.createRoom(request, userId); CreateRoomResponse result = gameService.createRoom(request, userId);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
@@ -71,7 +69,7 @@ public class GameController {
*/ */
@PostMapping("/room/join") @PostMapping("/room/join")
public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) { public ApiResponse<RoomItemResponse> joinRoom(@Valid @RequestBody JoinRoomRequest request) {
String userId = StpUtil.getLoginIdAsString(); String userId = authProvider.getCurrentUserId();
RoomItemResponse result = gameService.joinRoom(request, userId); RoomItemResponse result = gameService.joinRoom(request, userId);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
@@ -83,7 +81,7 @@ public class GameController {
*/ */
@PostMapping("/room/leave") @PostMapping("/room/leave")
public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) { public ApiResponse<Void> leaveRoom(@Valid @RequestBody LeaveRoomRequest request) {
String userId = StpUtil.getLoginIdAsString(); String userId = authProvider.getCurrentUserId();
gameService.leaveRoom(request.roomId(), userId); gameService.leaveRoom(request.roomId(), userId);
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }
@@ -95,7 +93,7 @@ public class GameController {
*/ */
@PostMapping("/room/kick") @PostMapping("/room/kick")
public ApiResponse<Void> kickPlayer(@Valid @RequestBody KickPlayerRequest request) { public ApiResponse<Void> kickPlayer(@Valid @RequestBody KickPlayerRequest request) {
String ownerId = StpUtil.getLoginIdAsString(); String ownerId = authProvider.getCurrentUserId();
gameService.kickPlayer(request.roomId(), ownerId, request.userId()); gameService.kickPlayer(request.roomId(), ownerId, request.userId());
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }
@@ -107,7 +105,7 @@ public class GameController {
*/ */
@PostMapping("/room/ready") @PostMapping("/room/ready")
public ApiResponse<Boolean> toggleReady(@Valid @RequestBody ReadyRequest request) { public ApiResponse<Boolean> toggleReady(@Valid @RequestBody ReadyRequest request) {
String userId = StpUtil.getLoginIdAsString(); String userId = authProvider.getCurrentUserId();
boolean ready = gameService.toggleReady(request.roomId(), userId); boolean ready = gameService.toggleReady(request.roomId(), userId);
return ApiResponse.ok(ready); return ApiResponse.ok(ready);
} }
@@ -117,7 +115,6 @@ public class GameController {
* *
* 无需登录即可查看(观战者也可查看)。 * 无需登录即可查看(观战者也可查看)。
*/ */
@SaIgnore
@GetMapping("/room/{roomId}") @GetMapping("/room/{roomId}")
public ApiResponse<RoomDetailResponse> roomDetail(@PathVariable String roomId) { public ApiResponse<RoomDetailResponse> roomDetail(@PathVariable String roomId) {
RoomDetailResponse result = gameService.getRoomDetail(roomId); RoomDetailResponse result = gameService.getRoomDetail(roomId);
@@ -132,7 +129,7 @@ public class GameController {
*/ */
@PostMapping("/room/start") @PostMapping("/room/start")
public ApiResponse<Void> startGame(@Valid @RequestBody StartGameRequest request) { public ApiResponse<Void> startGame(@Valid @RequestBody StartGameRequest request) {
String ownerId = StpUtil.getLoginIdAsString(); String ownerId = authProvider.getCurrentUserId();
gameService.startGame(request.roomId(), ownerId); gameService.startGame(request.roomId(), ownerId);
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.controller; package com.webgame.webgamebackend.controller;
import cn.dev33.satoken.stp.StpUtil; import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.dto.ApiResponse; import com.webgame.webgamebackend.common.dto.ApiResponse;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest; import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest; import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
@@ -22,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile;
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
private final AuthenticationProvider authProvider;
/** /**
* 获取当前登录用户信息 * 获取当前登录用户信息
@@ -39,7 +40,7 @@ public class UserController {
*/ */
@PutMapping("/info") @PutMapping("/info")
public ApiResponse<UserInfoResponse> updateInfo(@Valid @RequestBody UpdateUserInfoRequest request) { public ApiResponse<UserInfoResponse> updateInfo(@Valid @RequestBody UpdateUserInfoRequest request) {
String accountId = StpUtil.getLoginIdAsString(); String accountId = authProvider.getCurrentUserId();
UserInfoResponse result = userService.updateUserInfo(accountId, request); UserInfoResponse result = userService.updateUserInfo(accountId, request);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
@@ -51,7 +52,7 @@ public class UserController {
*/ */
@PatchMapping("/status") @PatchMapping("/status")
public ApiResponse<Void> updateStatus(@Valid @RequestBody UpdateStatusRequest request) { public ApiResponse<Void> updateStatus(@Valid @RequestBody UpdateStatusRequest request) {
String accountId = StpUtil.getLoginIdAsString(); String accountId = authProvider.getCurrentUserId();
userService.updateStatus(accountId, request); userService.updateStatus(accountId, request);
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }
@@ -64,7 +65,7 @@ public class UserController {
*/ */
@PostMapping("/avatar") @PostMapping("/avatar")
public ApiResponse<UserInfoResponse> uploadAvatar(@RequestParam("file") MultipartFile file) { public ApiResponse<UserInfoResponse> uploadAvatar(@RequestParam("file") MultipartFile file) {
String accountId = StpUtil.getLoginIdAsString(); String accountId = authProvider.getCurrentUserId();
UserInfoResponse result = userService.uploadAvatar(accountId, file); UserInfoResponse result = userService.uploadAvatar(accountId, file);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }
@@ -76,7 +77,7 @@ public class UserController {
*/ */
@DeleteMapping("/avatar") @DeleteMapping("/avatar")
public ApiResponse<UserInfoResponse> deleteAvatar() { public ApiResponse<UserInfoResponse> deleteAvatar() {
String accountId = StpUtil.getLoginIdAsString(); String accountId = authProvider.getCurrentUserId();
UserInfoResponse result = userService.deleteAvatar(accountId); UserInfoResponse result = userService.deleteAvatar(accountId);
return ApiResponse.ok(result); return ApiResponse.ok(result);
} }

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.service.impl; package com.webgame.webgamebackend.service.impl;
import cn.dev33.satoken.stp.StpUtil; import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.constants.UserConstants; import com.webgame.webgamebackend.common.constants.UserConstants;
import com.webgame.webgamebackend.common.dto.account.LoginRequest; import com.webgame.webgamebackend.common.dto.account.LoginRequest;
import com.webgame.webgamebackend.common.dto.account.LoginResponse; import com.webgame.webgamebackend.common.dto.account.LoginResponse;
@@ -33,6 +33,7 @@ public class AccountServiceImpl implements AccountService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder; private final BCryptPasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService; private final RefreshTokenService refreshTokenService;
private final AuthenticationProvider authProvider;
@Override @Override
public LoginResponse login(LoginRequest request) { public LoginResponse login(LoginRequest request) {
@@ -41,9 +42,8 @@ public class AccountServiceImpl implements AccountService {
if (!passwordEncoder.matches(request.password(), account.getPassword())) { if (!passwordEncoder.matches(request.password(), account.getPassword())) {
throw new BusinessException(ErrorCode.BAD_CREDENTIALS); throw new BusinessException(ErrorCode.BAD_CREDENTIALS);
} }
// Sa-Token 登录,生成 access token // 登录,生成 access token
StpUtil.login(account.getId()); String accessToken = authProvider.login(account.getId());
String accessToken = StpUtil.getTokenValue();
// 生成 refresh token // 生成 refresh token
String refreshToken = refreshTokenService.create(account.getId()); String refreshToken = refreshTokenService.create(account.getId());
log.info("用户登录成功: username={}", request.username()); log.info("用户登录成功: username={}", request.username());
@@ -75,8 +75,7 @@ public class AccountServiceImpl implements AccountService {
log.info("用户注册成功: username={}", request.username()); log.info("用户注册成功: username={}", request.username());
// 注册后自动登录,生成 access token 和 refresh token // 注册后自动登录,生成 access token 和 refresh token
StpUtil.login(account.getId()); String accessToken = authProvider.login(account.getId());
String accessToken = StpUtil.getTokenValue();
String refreshToken = refreshTokenService.create(account.getId()); String refreshToken = refreshTokenService.create(account.getId());
return new LoginResponse(accessToken, refreshToken); return new LoginResponse(accessToken, refreshToken);
} }
@@ -85,8 +84,7 @@ public class AccountServiceImpl implements AccountService {
public LoginResponse refresh(String refreshToken) { public LoginResponse refresh(String refreshToken) {
// 校验 refresh token有效则用它签发新的 access token // 校验 refresh token有效则用它签发新的 access token
String userId = refreshTokenService.validate(refreshToken); String userId = refreshTokenService.validate(refreshToken);
StpUtil.login(userId); String accessToken = authProvider.login(userId);
String accessToken = StpUtil.getTokenValue();
log.info("令牌刷新成功: userId={}", userId); log.info("令牌刷新成功: userId={}", userId);
// refresh token 保持不变,只返回新的 access token // refresh token 保持不变,只返回新的 access token
return new LoginResponse(accessToken, refreshToken); return new LoginResponse(accessToken, refreshToken);
@@ -95,9 +93,9 @@ public class AccountServiceImpl implements AccountService {
@Override @Override
public void logout(String refreshToken) { public void logout(String refreshToken) {
// 获取当前登录用户ID用于日志 // 获取当前登录用户ID用于日志
String loginId = (String) StpUtil.getLoginId(); String loginId = authProvider.getCurrentUserId();
// 登出 Sa-Token,使 access token 失效 // 登出,使 access token 失效
StpUtil.logout(); authProvider.logout();
// 撤销 refresh token使其在 Redis 中删除 // 撤销 refresh token使其在 Redis 中删除
refreshTokenService.revoke(refreshToken); refreshTokenService.revoke(refreshToken);
log.info("用户已登出: userId={}", loginId); log.info("用户已登出: userId={}", loginId);

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.service.impl; package com.webgame.webgamebackend.service.impl;
import cn.dev33.satoken.stp.StpUtil; import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest; import com.webgame.webgamebackend.common.dto.user.UpdateStatusRequest;
import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest; import com.webgame.webgamebackend.common.dto.user.UpdateUserInfoRequest;
import com.webgame.webgamebackend.common.dto.user.UserInfoResponse; import com.webgame.webgamebackend.common.dto.user.UserInfoResponse;
@@ -48,10 +48,11 @@ public class UserServiceImpl implements UserService {
private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024; private static final long MAX_AVATAR_SIZE = 2 * 1024 * 1024;
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuthenticationProvider authProvider;
@Override @Override
public UserInfoResponse getCurrentUserInfo() { public UserInfoResponse getCurrentUserInfo() {
String accountId = StpUtil.getLoginIdAsString(); String accountId = authProvider.getCurrentUserId();
log.info("[用户服务] 获取当前登录用户信息, accountId: {}", accountId); log.info("[用户服务] 获取当前登录用户信息, accountId: {}", accountId);
UserEntity user = userRepository.findByAccountId(accountId) UserEntity user = userRepository.findByAccountId(accountId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.sse; package com.webgame.webgamebackend.sse;
import cn.dev33.satoken.stp.StpUtil; import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -29,29 +29,28 @@ public class SseController {
private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L; private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L;
private final SseConnectionManager connectionManager; private final SseConnectionManager connectionManager;
private final AuthenticationProvider authProvider;
@GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter subscribe( public SseEmitter subscribe(
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId, @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
HttpServletResponse response) throws IOException { HttpServletResponse response) throws IOException {
// SSE 路径已排除在 SaInterceptor 之外,此处手动校验登录态 // SSE 路径已排除在 AuthInterceptor 之外,此处手动校验登录态
String token = StpUtil.getTokenValue(); String token = authProvider.getCurrentToken();
if (token == null || token.isBlank()) { if (token == null || token.isBlank()) {
log.warn("[SSE] 缺少认证 token"); log.warn("[SSE] 缺少认证 token");
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "缺少认证信息,请先登录"); return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "缺少认证信息,请先登录");
} }
Object loginId = StpUtil.getLoginIdByToken(token); String userId = authProvider.getUserIdByToken(token);
if (loginId == null) { if (userId == null) {
log.warn("[SSE] token 无效或已过期"); log.warn("[SSE] token 无效或已过期");
return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "登录已过期,请重新登录"); return writeAuthError(response, HttpServletResponse.SC_UNAUTHORIZED, "登录已过期,请重新登录");
} }
// 设置 Sa-Token 上下文,后续 StpUtil.getLoginIdAsString() 可用 // 设置认证上下文,后续 getCurrentUserId() 可用
StpUtil.setTokenValue(token, -1); authProvider.setContextToken(token, -1);
String userId = loginId.toString();
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS); SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
String connectionId = connectionManager.register(userId, emitter); String connectionId = connectionManager.register(userId, emitter);

View File

@@ -1,6 +1,6 @@
package com.webgame.webgamebackend.ws; package com.webgame.webgamebackend.ws;
import cn.dev33.satoken.stp.StpUtil; import com.webgame.webgamebackend.common.auth.AuthenticationProvider;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.webgame.webgamebackend.service.GomokuGameService; import com.webgame.webgamebackend.service.GomokuGameService;
@@ -35,6 +35,7 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
private final RoomSessionManager sessionManager; private final RoomSessionManager sessionManager;
private final GomokuGameService gomokuGameService; private final GomokuGameService gomokuGameService;
private final WsMessageRouter router; private final WsMessageRouter router;
private final AuthenticationProvider authProvider;
/** /**
* 注册所有消息类型与对应处理器 * 注册所有消息类型与对应处理器
@@ -109,12 +110,11 @@ public class RoomWebSocketHandler extends TextWebSocketHandler {
} }
// 通过 token 获取用户 ID // 通过 token 获取用户 ID
Object loginId = StpUtil.getLoginIdByToken(token); String userId = authProvider.getUserIdByToken(token);
if (loginId == null) { if (userId == null) {
session.close(CloseStatus.POLICY_VIOLATION); session.close(CloseStatus.POLICY_VIOLATION);
return; return;
} }
String userId = loginId.toString();
// 注册会话(内部会清理同用户的旧会话) // 注册会话(内部会清理同用户的旧会话)
sessionManager.addSession(roomId, userId, session); sessionManager.addSession(roomId, userId, session);