feat: HTTP状态码 + 业务码

This commit is contained in:
2026-06-24 11:14:42 +08:00
parent 61ff5d46e6
commit df6c7f6b10
3 changed files with 119 additions and 60 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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&lt;ApiResponse&gt; 实现双层错误表达:
* 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()));
}
}