feat: HTTP状态码 + 业务码
This commit is contained in:
@@ -3,26 +3,31 @@ package com.webgame.webgamebackend.common.exception;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 业务异常,Service 层抛出,由全局异常处理器转换为 ApiResponse
|
||||
* 业务异常,Service 层抛出,由全局异常处理器转换为 ResponseEntity<ApiResponse>
|
||||
* <p>
|
||||
* 同时携带业务错误码和对应的 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,47 +3,52 @@ package com.webgame.webgamebackend.common.exception;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 业务错误码枚举,统一管理所有错误码和默认消息
|
||||
* 业务错误码枚举,统一管理所有错误码、默认消息和对应的 HTTP 状态码
|
||||
* <p>
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Void> handleBusinessException(BusinessException ex) {
|
||||
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
|
||||
return ApiResponse.fail(ex.getCode(), ex.getMessage());
|
||||
public ResponseEntity<ApiResponse<Void>> 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<Void> handleValidation(MethodArgumentNotValidException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> 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<Void> handleNotLogin(NotLoginException ex) {
|
||||
log.warn("未登录: {}", ex.getMessage());
|
||||
return ApiResponse.fail(401, "请先登录");
|
||||
}
|
||||
|
||||
@ExceptionHandler({NotPermissionException.class, NotRoleException.class})
|
||||
public ApiResponse<Void> handleForbidden(Exception ex) {
|
||||
log.warn("权限不足: {}", ex.getMessage());
|
||||
return ApiResponse.fail(403, "权限不足");
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||
public ApiResponse<Void> 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<Void> handleMessageNotReadable(HttpMessageNotReadableException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> 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<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> 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<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, "权限不足"));
|
||||
}
|
||||
|
||||
// ==================== 框架级异常 ====================
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ApiResponse<Void> handleNoResourceFound(NoResourceFoundException ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> 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<Void> handleException(Exception ex) {
|
||||
public ResponseEntity<ApiResponse<Void>> 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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user