From df6c7f6b10b4bec8b305faa45ba87deecc20f4d9 Mon Sep 17 00:00:00 2001 From: Azure <983547216@qq.com> Date: Wed, 24 Jun 2026 11:14:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20HTTP=E7=8A=B6=E6=80=81=E7=A0=81=20+=20?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/BusinessException.java | 11 +- .../common/exception/ErrorCode.java | 53 ++++---- .../handler/GlobalExceptionHandler.java | 115 +++++++++++++----- 3 files changed, 119 insertions(+), 60 deletions(-) diff --git a/src/main/java/com/webgame/webgamebackend/common/exception/BusinessException.java b/src/main/java/com/webgame/webgamebackend/common/exception/BusinessException.java index b301bec..92afef3 100644 --- a/src/main/java/com/webgame/webgamebackend/common/exception/BusinessException.java +++ b/src/main/java/com/webgame/webgamebackend/common/exception/BusinessException.java @@ -3,26 +3,31 @@ package com.webgame.webgamebackend.common.exception; import lombok.Getter; /** - * 业务异常,Service 层抛出,由全局异常处理器转换为 ApiResponse + * 业务异常,Service 层抛出,由全局异常处理器转换为 ResponseEntity<ApiResponse> + *

+ * 同时携带业务错误码和对应的 HTTP 状态码,两者均从 ErrorCode 枚举自动派生。 */ @Getter public class BusinessException extends RuntimeException { private final int code; + private final int httpStatus; /** - * 使用 ErrorCode 枚举创建异常,消息使用枚举默认值 + * 使用 ErrorCode 枚举创建异常,消息和 HTTP 状态码从枚举默认值派生 */ public BusinessException(ErrorCode errorCode) { super(errorCode.getDefaultMessage()); this.code = errorCode.getCode(); + this.httpStatus = errorCode.getHttpStatus(); } /** - * 使用 ErrorCode 枚举 + 自定义消息创建异常 + * 使用 ErrorCode 枚举 + 自定义消息创建异常,HTTP 状态码从枚举派生 */ public BusinessException(ErrorCode errorCode, String message) { super(message); this.code = errorCode.getCode(); + this.httpStatus = errorCode.getHttpStatus(); } } diff --git a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java index 706c48b..f8f8dc6 100644 --- a/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java +++ b/src/main/java/com/webgame/webgamebackend/common/exception/ErrorCode.java @@ -3,47 +3,52 @@ package com.webgame.webgamebackend.common.exception; import lombok.Getter; /** - * 业务错误码枚举,统一管理所有错误码和默认消息 + * 业务错误码枚举,统一管理所有错误码、默认消息和对应的 HTTP 状态码 + *

+ * HTTP 状态码表达错误大类(协议层),code 表达具体业务错误(应用层), + * 两者分工协作形成双层错误表达模式。 */ @Getter public enum ErrorCode { // ========== 通用 ========== - SUCCESS(0, "成功"), - BAD_REQUEST(400, "参数校验失败"), - INTERNAL_ERROR(5000, "服务器内部错误"), + SUCCESS(0, "成功", 200), + BAD_REQUEST(400, "参数校验失败", 400), + INTERNAL_ERROR(5000, "服务器内部错误", 500), // ========== 账号模块 ========== - USERNAME_EXISTS(1001, "用户名已存在"), - BAD_CREDENTIALS(1002, "用户名或密码错误"), - REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期"), - REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌"), + USERNAME_EXISTS(1001, "用户名已存在", 409), + BAD_CREDENTIALS(1002, "用户名或密码错误", 401), + REFRESH_TOKEN_INVALID(1003, "刷新令牌无效或已过期", 401), + REFRESH_TOKEN_MISSING(1004, "缺少刷新令牌", 400), // ========== 用户模块 ========== - USER_NOT_FOUND(2001, "用户不存在"), - AVATAR_UPLOAD_FAILED(2002, "头像上传失败"), - AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF"), - AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB"), + USER_NOT_FOUND(2001, "用户不存在", 404), + AVATAR_UPLOAD_FAILED(2002, "头像上传失败", 500), + AVATAR_INVALID_TYPE(2003, "头像格式不支持,仅支持 JPG、PNG、WebP、GIF", 400), + AVATAR_TOO_LARGE(2004, "头像文件大小不能超过 2MB", 400), // ========== 游戏模块 ========== - GAME_NOT_FOUND(3001, "游戏不存在或已下架"), - ROOM_NOT_FOUND(3002, "房间不存在"), - ROOM_FULL(3003, "房间已满"), - ROOM_PASSWORD_ERROR(3004, "房间密码错误"), - ROOM_ALREADY_STARTED(3005, "游戏已开始,无法加入"), - NOT_ROOM_OWNER(3006, "非房主无权操作"), - PLAYER_NOT_IN_ROOM(3007, "玩家不在该房间"), - NOT_ALL_READY(3008, "有玩家尚未准备"), - PLAYER_COUNT_INSUFFICIENT(3009, "人数不足,无法开始"), - BALANCE_INSUFFICIENT(3010, "余额不足"), - ALREADY_IN_ROOM(3011, "已在房间中"), + GAME_NOT_FOUND(3001, "游戏不存在或已下架", 404), + ROOM_NOT_FOUND(3002, "房间不存在", 404), + ROOM_FULL(3003, "房间已满", 409), + ROOM_PASSWORD_ERROR(3004, "房间密码错误", 401), + ROOM_ALREADY_STARTED(3005, "游戏已开始,无法加入", 409), + NOT_ROOM_OWNER(3006, "非房主无权操作", 403), + PLAYER_NOT_IN_ROOM(3007, "玩家不在该房间", 404), + NOT_ALL_READY(3008, "有玩家尚未准备", 409), + PLAYER_COUNT_INSUFFICIENT(3009, "人数不足,无法开始", 409), + BALANCE_INSUFFICIENT(3010, "余额不足", 409), + ALREADY_IN_ROOM(3011, "已在房间中", 409), ; private final int code; private final String defaultMessage; + private final int httpStatus; - ErrorCode(int code, String defaultMessage) { + ErrorCode(int code, String defaultMessage, int httpStatus) { this.code = code; this.defaultMessage = defaultMessage; + this.httpStatus = httpStatus; } } diff --git a/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java b/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java index 70eaedd..7f93ffe 100644 --- a/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/webgame/webgamebackend/common/handler/GlobalExceptionHandler.java @@ -8,6 +8,8 @@ import com.webgame.webgamebackend.common.dto.ApiResponse; import com.webgame.webgamebackend.common.exception.BusinessException; import com.webgame.webgamebackend.common.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -16,81 +18,128 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.context.request.async.AsyncRequestNotUsableException; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.servlet.resource.NoResourceFoundException; +/** + * 全局异常处理器,返回 ResponseEntity<ApiResponse> 实现双层错误表达: + * HTTP 状态码表达错误大类 + ApiResponse.code 表达具体业务错误码 + */ @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + // ==================== SSE / 长连接异常(不写响应体) ==================== + @ExceptionHandler(AsyncRequestTimeoutException.class) public void handleAsyncTimeout(AsyncRequestTimeoutException ex) { - // SSE 超时在长连接场景下属于正常情况 + // SSE 超时在长连接场景下属于正常情况,不写响应体 } @ExceptionHandler(SaTokenContextException.class) public void handleSaTokenContext(SaTokenContextException ex) { log.warn("Sa-Token 上下文不可用: {}", ex.getMessage()); + // 长连接场景,不写响应体 } @ExceptionHandler(AsyncRequestNotUsableException.class) public void handleAsyncRequestNotUsable(AsyncRequestNotUsableException ex) { - // SSE 响应已被客户端关闭 + // SSE 响应已被客户端关闭,不写响应体 } + // ==================== 业务异常 ==================== + + /** + * 业务异常 — HTTP 状态码从 ErrorCode 枚举自动派生 + */ @ExceptionHandler(BusinessException.class) - public ApiResponse handleBusinessException(BusinessException ex) { - log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage()); - return ApiResponse.fail(ex.getCode(), ex.getMessage()); + public ResponseEntity> handleBusinessException(BusinessException ex) { + log.warn("业务异常: code={}, httpStatus={}, message={}", ex.getCode(), ex.getHttpStatus(), ex.getMessage()); + return ResponseEntity + .status(ex.getHttpStatus()) + .body(ApiResponse.fail(ex.getCode(), ex.getMessage())); } + // ==================== 参数校验异常 → HTTP 400 ==================== + @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiResponse handleValidation(MethodArgumentNotValidException ex) { + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldErrors().stream() .map(error -> error.getField() + " " + error.getDefaultMessage()) .reduce((a, b) -> a + "; " + b) .orElse(ErrorCode.BAD_REQUEST.getDefaultMessage()); - return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), message); - } - - @ExceptionHandler(NotLoginException.class) - public ApiResponse handleNotLogin(NotLoginException ex) { - log.warn("未登录: {}", ex.getMessage()); - return ApiResponse.fail(401, "请先登录"); - } - - @ExceptionHandler({NotPermissionException.class, NotRoleException.class}) - public ApiResponse handleForbidden(Exception ex) { - log.warn("权限不足: {}", ex.getMessage()); - return ApiResponse.fail(403, "权限不足"); - } - - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ApiResponse handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { - log.warn("请求方法不支持: {}", ex.getMessage()); - return ApiResponse.fail(405, "请求方法不支持"); + return ResponseEntity + .badRequest() + .body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), message)); } @ExceptionHandler(HttpMessageNotReadableException.class) - public ApiResponse handleMessageNotReadable(HttpMessageNotReadableException ex) { + public ResponseEntity> handleMessageNotReadable(HttpMessageNotReadableException ex) { log.warn("请求体解析失败: {}", ex.getMessage()); - return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式无效"); + return ResponseEntity + .badRequest() + .body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "请求体格式无效")); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ApiResponse handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { log.warn("参数类型不匹配: {}", ex.getMessage()); - return ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型无效"); + return ResponseEntity + .badRequest() + .body(ApiResponse.fail(ErrorCode.BAD_REQUEST.getCode(), "参数类型无效")); } + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity> handleMaxUploadSize(MaxUploadSizeExceededException ex) { + log.warn("文件上传超过大小限制: {}", ex.getMessage()); + return ResponseEntity + .badRequest() + .body(ApiResponse.fail(ErrorCode.AVATAR_TOO_LARGE.getCode(), "文件大小不能超过 2MB")); + } + + // ==================== 认证/授权异常 → HTTP 401 / 403 ==================== + + @ExceptionHandler(NotLoginException.class) + public ResponseEntity> handleNotLogin(NotLoginException ex) { + log.warn("未登录: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.fail(401, "请先登录")); + } + + @ExceptionHandler({NotPermissionException.class, NotRoleException.class}) + public ResponseEntity> handleForbidden(Exception ex) { + log.warn("权限不足: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.FORBIDDEN) + .body(ApiResponse.fail(403, "权限不足")); + } + + // ==================== 框架级异常 ==================== + @ExceptionHandler(NoResourceFoundException.class) - public ApiResponse handleNoResourceFound(NoResourceFoundException ex) { + public ResponseEntity> handleNoResourceFound(NoResourceFoundException ex) { log.warn("资源未找到: {}", ex.getMessage()); - return ApiResponse.fail(404, "资源未找到"); + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponse.fail(404, "资源未找到")); } + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupported(HttpRequestMethodNotSupportedException ex) { + log.warn("请求方法不支持: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ApiResponse.fail(405, "请求方法不支持")); + } + + // ==================== 兜底 → HTTP 500 ==================== + @ExceptionHandler(Exception.class) - public ApiResponse handleException(Exception ex) { + public ResponseEntity> handleException(Exception ex) { log.error("服务器内部错误", ex); - return ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getDefaultMessage()); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.fail(ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getDefaultMessage())); } }