mirror of
https://github.com/hs-web/hsweb-framework.git
synced 2026-06-20 09:52:14 +08:00
Merge branch 'i18n-support'
This commit is contained in:
@@ -55,5 +55,5 @@ public @interface TwoFactor {
|
||||
* @return 错误提示
|
||||
* @since 3.0.6
|
||||
*/
|
||||
String message() default "需要进行双因子验证";
|
||||
String message() default "validation.verify_code_error";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.hswebframework.web.authorization.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.hswebframework.web.exception.I18nSupportException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 权限验证异常
|
||||
*
|
||||
@@ -11,7 +14,7 @@ import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
* @since 3.0
|
||||
*/
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public class AccessDenyException extends RuntimeException {
|
||||
public class AccessDenyException extends I18nSupportException {
|
||||
|
||||
private static final long serialVersionUID = -5135300127303801430L;
|
||||
|
||||
@@ -19,16 +22,21 @@ public class AccessDenyException extends RuntimeException {
|
||||
private String code;
|
||||
|
||||
public AccessDenyException() {
|
||||
this("权限不足,拒绝访问!");
|
||||
this("error.access_denied");
|
||||
}
|
||||
|
||||
public AccessDenyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AccessDenyException(String permission, Set<String> actions) {
|
||||
super("error.permission_denied", permission, actions);
|
||||
}
|
||||
|
||||
public AccessDenyException(String message, String code) {
|
||||
this(message, code, null);
|
||||
}
|
||||
|
||||
public AccessDenyException(String message, Throwable cause) {
|
||||
this(message, "access_denied", cause);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package org.hswebframework.web.authorization.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.hswebframework.web.exception.I18nSupportException;
|
||||
|
||||
@Getter
|
||||
public class AuthenticationException extends RuntimeException {
|
||||
public class AuthenticationException extends I18nSupportException {
|
||||
|
||||
|
||||
public static String ILLEGAL_PASSWORD = "illegal_password";
|
||||
@@ -13,6 +14,10 @@ public class AuthenticationException extends RuntimeException {
|
||||
|
||||
private final String code;
|
||||
|
||||
public AuthenticationException(String code) {
|
||||
this(code, "error." + code);
|
||||
}
|
||||
|
||||
public AuthenticationException(String code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package org.hswebframework.web.authorization.exception;
|
||||
|
||||
import org.hswebframework.web.authorization.token.TokenState;
|
||||
import org.hswebframework.web.exception.I18nSupportException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@@ -29,7 +30,7 @@ import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
* @since 3.0
|
||||
*/
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
public class UnAuthorizedException extends RuntimeException {
|
||||
public class UnAuthorizedException extends I18nSupportException {
|
||||
private static final long serialVersionUID = 2422918455013900645L;
|
||||
|
||||
private final TokenState state;
|
||||
|
||||
@@ -253,7 +253,7 @@ public class DefaultUserTokenManager implements UserTokenManager {
|
||||
.flatMap(this::checkTimeout)
|
||||
.filterWhen(t -> {
|
||||
if (t.isNormal()) {
|
||||
return Mono.error(new AccessDenyException("该用户已在其他地方登陆"));
|
||||
return Mono.error(new AccessDenyException("error.logged_in_elsewhere"));
|
||||
}
|
||||
return Mono.empty();
|
||||
})
|
||||
|
||||
@@ -13,30 +13,30 @@ public enum TokenState implements EnumDict<String> {
|
||||
/**
|
||||
* 正常,有效
|
||||
*/
|
||||
normal("normal","正常"),
|
||||
normal("normal","message.token_state_normal"),
|
||||
|
||||
/**
|
||||
* 已被禁止访问
|
||||
*/
|
||||
deny("deny", "已被禁止访问"),
|
||||
deny("deny", "message.token_state_deny"),
|
||||
|
||||
/**
|
||||
* 已过期
|
||||
*/
|
||||
expired("expired", "用户未登录"),
|
||||
expired("expired", "message.token_state_expired"),
|
||||
|
||||
/**
|
||||
* 已被踢下线
|
||||
* @see AllopatricLoginMode#offlineOther
|
||||
*/
|
||||
offline("offline", "用户已在其他地方登录"),
|
||||
offline("offline", "message.token_state_offline"),
|
||||
|
||||
/**
|
||||
* 锁定
|
||||
*/
|
||||
lock("lock", "登录状态已被锁定");
|
||||
lock("lock", "message.token_state_lock");
|
||||
|
||||
private String value;
|
||||
private final String value;
|
||||
|
||||
private String text;
|
||||
private final String text;
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ public class RedisUserTokenManager implements UserTokenManager {
|
||||
return userIsLoggedIn(userId)
|
||||
.flatMap(r -> {
|
||||
if (r) {
|
||||
return Mono.error(new AccessDenyException("已在其他地方登录", TokenState.deny.getValue(), null));
|
||||
return Mono.error(new AccessDenyException("error.logged_in_elsewhere", TokenState.deny.getValue(), null));
|
||||
}
|
||||
return doSign;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
error.access_denied=Access Denied
|
||||
error.permission_denied=Permission Denied [{0}]:{1}
|
||||
error.logged_in_elsewhere=User logged in elsewhere
|
||||
error.illegal_password=Bad username or password
|
||||
error.user_disabled=User is disabled
|
||||
#
|
||||
message.token_state_normal=Normal
|
||||
message.token_state_deny=Login has denied
|
||||
message.token_state_expired=Login has expired
|
||||
message.token_state_offline=User logged in elsewhere
|
||||
message.token_state_lock=User Locked
|
||||
#
|
||||
validation.need_two_factor_verify=Two factor verification required
|
||||
validation.username_must_not_be_empty=Username must not be empty
|
||||
validation.password_must_not_be_empty=Password must not be empty
|
||||
validation.verify_code_error=Verification code error
|
||||
@@ -0,0 +1,16 @@
|
||||
error.access_denied=权限不足,拒绝访问!
|
||||
error.permission_denied=当前用户无权限[{0}]:{1}
|
||||
error.logged_in_elsewhere=该用户已在其他地方登陆
|
||||
error.illegal_password=用户名或密码错误
|
||||
error.user_disabled=用户已被禁用
|
||||
#
|
||||
message.token_state_normal=正常
|
||||
message.token_state_deny=已被禁止访问
|
||||
message.token_state_expired=用户未登录
|
||||
message.token_state_offline=用户已在其他地方登录
|
||||
message.token_state_lock=登录状态已被锁定
|
||||
#
|
||||
validation.need_two_factor_verify=需要双因子验证
|
||||
validation.username_must_not_be_empty=用户名不能为空
|
||||
validation.password_must_not_be_empty=密码不能为空
|
||||
validation.verify_code_error=验证码错误
|
||||
@@ -36,7 +36,7 @@ public class DefaultBasicAuthorizeDefinition implements AopAuthorizeDefinition {
|
||||
private ResourcesDefinition resources = new ResourcesDefinition();
|
||||
private DimensionsDefinition dimensions = new DimensionsDefinition();
|
||||
|
||||
private String message = "权限不足,拒绝访问";
|
||||
private String message = "error.access_denied";
|
||||
|
||||
private Phased phased;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import org.hswebframework.web.authorization.annotation.TwoFactor;
|
||||
import org.hswebframework.web.authorization.exception.NeedTwoFactorException;
|
||||
import org.hswebframework.web.authorization.twofactor.TwoFactorValidator;
|
||||
import org.hswebframework.web.authorization.twofactor.TwoFactorValidatorManager;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
|
||||
@@ -22,7 +21,7 @@ import javax.servlet.http.HttpServletResponse;
|
||||
@AllArgsConstructor
|
||||
public class TwoFactorHandlerInterceptorAdapter extends HandlerInterceptorAdapter {
|
||||
|
||||
private TwoFactorValidatorManager validatorManager;
|
||||
private final TwoFactorValidatorManager validatorManager;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
@@ -45,9 +44,9 @@ public class TwoFactorHandlerInterceptorAdapter extends HandlerInterceptorAdapte
|
||||
code = request.getHeader(factor.parameter());
|
||||
}
|
||||
if (StringUtils.isEmpty(code)) {
|
||||
throw new NeedTwoFactorException(factor.message(), factor.provider());
|
||||
throw new NeedTwoFactorException("validation.need_two_factor_verify", factor.provider());
|
||||
} else if (!validator.verify(code, factor.timeout())) {
|
||||
throw new NeedTwoFactorException("验证码错误", factor.provider());
|
||||
throw new NeedTwoFactorException(factor.message(), factor.provider());
|
||||
}
|
||||
}
|
||||
return super.preHandle(request, response, handler);
|
||||
|
||||
@@ -34,7 +34,6 @@ import org.hswebframework.web.authorization.simple.PlainTextUsernamePasswordAuth
|
||||
import org.hswebframework.web.logging.AccessLogger;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -85,8 +84,8 @@ public class AuthorizationController {
|
||||
String username_ = (String) parameters.get("username");
|
||||
String password_ = (String) parameters.get("password");
|
||||
|
||||
Assert.hasLength(username_, "用户名不能为空");
|
||||
Assert.hasLength(password_, "密码不能为空");
|
||||
Assert.hasLength(username_, "validation.username_must_not_be_empty");
|
||||
Assert.hasLength(password_, "validation.password_must_not_be_empty");
|
||||
|
||||
Function<String, Object> parameterGetter = parameters::get;
|
||||
return Mono.defer(() -> {
|
||||
@@ -101,7 +100,7 @@ public class AuthorizationController {
|
||||
.publish(eventPublisher)
|
||||
.then(authenticationManager
|
||||
.authenticate(Mono.just(new PlainTextUsernamePasswordAuthenticationRequest(username, password)))
|
||||
.switchIfEmpty(Mono.error(() -> new AuthenticationException(AuthenticationException.ILLEGAL_PASSWORD,"密码错误")))
|
||||
.switchIfEmpty(Mono.error(() -> new AuthenticationException(AuthenticationException.ILLEGAL_PASSWORD)))
|
||||
.flatMap(auth -> {
|
||||
//触发授权成功事件
|
||||
AuthorizationSuccessEvent event = new AuthorizationSuccessEvent(auth, parameterGetter);
|
||||
|
||||
@@ -8,7 +8,7 @@ public class OAuth2Exception extends BusinessException {
|
||||
private final ErrorType type;
|
||||
|
||||
public OAuth2Exception(ErrorType type) {
|
||||
super(type.message(), type.name(), type.code());
|
||||
super(type.message(), type.name(), type.code(), (Object[]) null);
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ public class MapperEntityFactory implements EntityFactory, BeanFactory {
|
||||
if (realType == null) {
|
||||
if (!Modifier.isInterface(beanClass.getModifiers()) && !Modifier.isAbstract(beanClass.getModifiers())) {
|
||||
realType = beanClass;
|
||||
}else {
|
||||
} else {
|
||||
mapper = defaultMapperFactory.apply(beanClass);
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ public class MapperEntityFactory implements EntityFactory, BeanFactory {
|
||||
return (T) new HashSet<>();
|
||||
}
|
||||
|
||||
throw new NotFoundException("无法初始化实体类:"+beanClass);
|
||||
throw new NotFoundException("error.cant_create_instance", beanClass);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -5,11 +5,14 @@ import org.hswebframework.ezorm.rdb.events.EventListener;
|
||||
import org.hswebframework.ezorm.rdb.events.EventType;
|
||||
import org.hswebframework.ezorm.rdb.mapping.events.MappingContextKeys;
|
||||
import org.hswebframework.ezorm.rdb.mapping.events.MappingEventTypes;
|
||||
import org.hswebframework.ezorm.rdb.mapping.events.ReactiveResultHolder;
|
||||
import org.hswebframework.web.api.crud.entity.Entity;
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
import org.hswebframework.web.validator.CreateGroup;
|
||||
import org.hswebframework.web.validator.UpdateGroup;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public class ValidateEventListener implements EventListener {
|
||||
|
||||
@@ -24,33 +27,49 @@ public class ValidateEventListener implements EventListener {
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("all")
|
||||
|
||||
public void onEvent(EventType type, EventContext context) {
|
||||
Optional<ReactiveResultHolder> resultHolder = context.get(MappingContextKeys.reactiveResultHolder);
|
||||
|
||||
if (resultHolder.isPresent()) {
|
||||
resultHolder
|
||||
.ifPresent(holder -> holder
|
||||
.before(LocaleUtils
|
||||
.currentReactive()
|
||||
.doOnNext(locale -> LocaleUtils.doWith(locale, (l) -> tryValidate(type, context)))
|
||||
.then()
|
||||
));
|
||||
} else {
|
||||
tryValidate(type, context);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("all")
|
||||
public void tryValidate(EventType type, EventContext context) {
|
||||
if (type == MappingEventTypes.insert_before || type == MappingEventTypes.save_before) {
|
||||
|
||||
boolean single = context.get(MappingContextKeys.type).map("single"::equals).orElse(false);
|
||||
if (single) {
|
||||
context.get(MappingContextKeys.instance)
|
||||
.filter(Entity.class::isInstance)
|
||||
.map(Entity.class::cast)
|
||||
.ifPresent(entity -> entity.tryValidate(CreateGroup.class));
|
||||
.filter(Entity.class::isInstance)
|
||||
.map(Entity.class::cast)
|
||||
.ifPresent(entity -> entity.tryValidate(CreateGroup.class));
|
||||
} else {
|
||||
context.get(MappingContextKeys.instance)
|
||||
.filter(List.class::isInstance)
|
||||
.map(List.class::cast)
|
||||
.ifPresent(lst -> lst.stream()
|
||||
.filter(Entity.class::isInstance)
|
||||
.map(Entity.class::cast)
|
||||
.forEach(e -> ((Entity) e).tryValidate(CreateGroup.class))
|
||||
);
|
||||
.filter(List.class::isInstance)
|
||||
.map(List.class::cast)
|
||||
.ifPresent(lst -> lst.stream()
|
||||
.filter(Entity.class::isInstance)
|
||||
.map(Entity.class::cast)
|
||||
.forEach(e -> ((Entity) e).tryValidate(CreateGroup.class))
|
||||
);
|
||||
}
|
||||
|
||||
} else if (type == MappingEventTypes.update_before) {
|
||||
context.get(MappingContextKeys.instance)
|
||||
.filter(Entity.class::isInstance)
|
||||
.map(Entity.class::cast)
|
||||
.ifPresent(entity -> entity.tryValidate(UpdateGroup.class));
|
||||
.filter(Entity.class::isInstance)
|
||||
.map(Entity.class::cast)
|
||||
.ifPresent(entity -> entity.tryValidate(UpdateGroup.class));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.hswebframework.web.crud.web;
|
||||
|
||||
import io.r2dbc.spi.R2dbcDataIntegrityViolationException;
|
||||
import io.r2dbc.spi.R2dbcNonTransientException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hswebframework.web.authorization.exception.AccessDenyException;
|
||||
import org.hswebframework.web.authorization.exception.AuthenticationException;
|
||||
@@ -10,11 +9,11 @@ import org.hswebframework.web.authorization.token.TokenState;
|
||||
import org.hswebframework.web.exception.BusinessException;
|
||||
import org.hswebframework.web.exception.NotFoundException;
|
||||
import org.hswebframework.web.exception.ValidationException;
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
import org.hswebframework.web.logger.ReactiveLogger;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.codec.DecodingException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
@@ -23,10 +22,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.bind.support.WebExchangeBindException;
|
||||
import org.springframework.web.server.MediaTypeNotSupportedStatusException;
|
||||
import org.springframework.web.server.MethodNotAllowedException;
|
||||
import org.springframework.web.server.NotAcceptableStatusException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import org.springframework.web.server.*;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import javax.validation.ConstraintViolationException;
|
||||
@@ -40,48 +36,66 @@ import java.util.stream.Collectors;
|
||||
@Order
|
||||
public class CommonErrorControllerAdvice {
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public CommonErrorControllerAdvice(MessageSource messageSource) {
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public Mono<ResponseMessage<Object>> handleException(BusinessException e) {
|
||||
return Mono.just(ResponseMessage.error(e.getCode(), e.getMessage()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource,
|
||||
e,
|
||||
(err, msg) -> ResponseMessage.error(err.getStatus(), err.getCode(), msg));
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public Mono<ResponseMessage<?>> handleException(UnsupportedOperationException e) {
|
||||
return Mono.just(ResponseMessage.error("unsupported", e.getMessage()));
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource, e, (err, msg) -> (ResponseMessage.<TokenState>error(401, "unsupported", msg)));
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||
public Mono<ResponseMessage<TokenState>> handleException(UnAuthorizedException e) {
|
||||
return Mono.just(ResponseMessage.<TokenState>error(401, "unauthorized", e.getMessage()).result(e.getState()));
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource, e, (err, msg) -> (ResponseMessage.<TokenState>error(401, "unauthorized", msg)
|
||||
.result(e.getState())));
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||
public Mono<ResponseMessage<?>> handleException(AccessDenyException e) {
|
||||
return Mono.just(ResponseMessage.error(403, e.getCode(), e.getMessage()));
|
||||
public Mono<ResponseMessage<Object>> handleException(AccessDenyException e) {
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage.error(403, e.getCode(), e.getMessage()))
|
||||
;
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public Mono<ResponseMessage<?>> handleException(NotFoundException e) {
|
||||
return Mono.just(ResponseMessage.error(404, "not_found", e.getMessage()));
|
||||
public Mono<ResponseMessage<Object>> handleException(NotFoundException e) {
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage.error(404, "not_found", msg))
|
||||
;
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Mono<ResponseMessage<List<ValidationException.Detail>>> handleException(ValidationException e) {
|
||||
return Mono.just(ResponseMessage.<List<ValidationException.Detail>>error(400, "illegal_argument", e.getMessage())
|
||||
.result(e.getDetails()));
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage
|
||||
.<List<ValidationException.Detail>>error(400, "illegal_argument",msg)
|
||||
.result(e.getDetails()))
|
||||
;
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Mono<ResponseMessage<List<ValidationException.Detail>>> handleException(ConstraintViolationException e) {
|
||||
return handleException(new ValidationException(e.getMessage(), e.getConstraintViolations()));
|
||||
return handleException(new ValidationException(e.getConstraintViolations()));
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@@ -130,6 +144,7 @@ public class CommonErrorControllerAdvice {
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.GATEWAY_TIMEOUT)
|
||||
public Mono<ResponseMessage<Object>> handleException(TimeoutException e) {
|
||||
|
||||
return Mono.just(ResponseMessage.error(504, "timeout", e.getMessage()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
|
||||
@@ -155,51 +170,70 @@ public class CommonErrorControllerAdvice {
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Mono<ResponseMessage<Object>> handleException(IllegalArgumentException e) {
|
||||
return Mono.just(ResponseMessage.error(400, "illegal_argument", e.getMessage()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage.error(400, "illegal_argument", msg))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)))
|
||||
;
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Mono<ResponseMessage<Object>> handleException(AuthenticationException e) {
|
||||
return Mono.just(ResponseMessage.error(400, e.getCode(), e.getMessage()));
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage.error(400, e.getCode(), msg))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getLocalizedMessage(), e)))
|
||||
;
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||
public Mono<ResponseMessage<Object>> handleException(MediaTypeNotSupportedStatusException e) {
|
||||
return Mono.just(ResponseMessage
|
||||
.error(415, "unsupported_media_type", "不支持的请求类型")
|
||||
.result(e.getSupportedMediaTypes()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
public Mono<ResponseMessage<Object>> handleException(UnsupportedMediaTypeStatusException e) {
|
||||
return LocaleUtils
|
||||
.resolveMessageReactive(messageSource, "error.unsupported_media_type")
|
||||
.map(msg -> ResponseMessage
|
||||
.error(415, "unsupported_media_type", msg)
|
||||
.result(e.getSupportedMediaTypes()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getLocalizedMessage(), e)));
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
|
||||
public Mono<ResponseMessage<Object>> handleException(NotAcceptableStatusException e) {
|
||||
return Mono.just(ResponseMessage
|
||||
.error(406, "not_acceptable_media_type", "不支持的响应类型")
|
||||
.result(e.getSupportedMediaTypes()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
|
||||
return LocaleUtils
|
||||
.resolveMessageReactive(messageSource, "error.not_acceptable_media_type")
|
||||
.map(msg -> ResponseMessage
|
||||
.error(406, "not_acceptable_media_type", msg)
|
||||
.result(e.getSupportedMediaTypes()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
|
||||
public Mono<ResponseMessage<Object>> handleException(MethodNotAllowedException e) {
|
||||
return Mono.just(ResponseMessage
|
||||
.error(405, "method_not_allowed", "不支持的请求方法:" + e.getHttpMethod())
|
||||
.result(e.getSupportedMethods()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
return LocaleUtils
|
||||
.resolveMessageReactive(messageSource, "error.method_not_allowed")
|
||||
.map(msg -> ResponseMessage
|
||||
.error(406, "method_not_allowed", msg)
|
||||
.result(e.getSupportedMethods()))
|
||||
.doOnEach(ReactiveLogger.onNext(r -> log.error(e.getMessage(), e)));
|
||||
}
|
||||
|
||||
@ExceptionHandler
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public Mono<ResponseMessage<Object>> handleException(R2dbcDataIntegrityViolationException exception) {
|
||||
if (exception.getMessage().contains("Duplicate")) {
|
||||
return Mono.just(ResponseMessage.error("存在重复的数据"));
|
||||
public Mono<ResponseMessage<Object>> handleException(R2dbcDataIntegrityViolationException e) {
|
||||
String code;
|
||||
|
||||
if (e.getMessage().contains("Duplicate")) {
|
||||
code = "error.duplicate_data";
|
||||
} else {
|
||||
code = "error.data_error";
|
||||
log.warn(e.getMessage(), e);
|
||||
}
|
||||
log.warn(exception.getMessage(), exception);
|
||||
return Mono.just(ResponseMessage.error("数据错误"));
|
||||
return LocaleUtils
|
||||
.resolveMessageReactive(messageSource, code)
|
||||
.map(msg -> ResponseMessage.error(400, code, msg));
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +249,10 @@ public class CommonErrorControllerAdvice {
|
||||
|
||||
} while (exception != null && exception != e);
|
||||
|
||||
return Mono.just(ResponseMessage.error(400, "illegal_argument", e.getMessage()));
|
||||
return LocaleUtils
|
||||
.resolveThrowable(messageSource,
|
||||
exception,
|
||||
(err, msg) -> ResponseMessage.error(400, "illegal_argument", msg));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package org.hswebframework.web.crud.web;
|
||||
|
||||
import org.hswebframework.web.i18n.WebFluxLocaleFilter;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.ReactiveAdapterRegistry;
|
||||
import org.springframework.http.codec.ServerCodecConfigurer;
|
||||
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
|
||||
@@ -16,17 +19,24 @@ public class CommonWebFluxConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public CommonErrorControllerAdvice commonErrorControllerAdvice(){
|
||||
return new CommonErrorControllerAdvice();
|
||||
public CommonErrorControllerAdvice commonErrorControllerAdvice(MessageSource messageSource) {
|
||||
return new CommonErrorControllerAdvice(messageSource);
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "hsweb.webflux.response-wrapper",name = "enabled",havingValue = "true",matchIfMissing = true)
|
||||
@ConditionalOnProperty(prefix = "hsweb.webflux.response-wrapper", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
@ConfigurationProperties(prefix = "hsweb.webflux.response-wrapper")
|
||||
public ResponseMessageWrapper responseMessageWrapper(ServerCodecConfigurer codecConfigurer,
|
||||
RequestedContentTypeResolver resolver,
|
||||
ReactiveAdapterRegistry registry){
|
||||
return new ResponseMessageWrapper(codecConfigurer.getWriters(),resolver,registry);
|
||||
ReactiveAdapterRegistry registry) {
|
||||
return new ResponseMessageWrapper(codecConfigurer.getWriters(), resolver, registry);
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public WebFilter localeWebFilter() {
|
||||
return new WebFluxLocaleFilter();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
error.unsupported_media_type=Unsupported media type
|
||||
error.not_acceptable_media_type=Not acceptable media type
|
||||
error.method_not_allowed=Method not allowed
|
||||
error.duplicate_data=Duplicate data
|
||||
error.data_error=Data error
|
||||
@@ -0,0 +1,5 @@
|
||||
error.unsupported_media_type=不支持的请求类型
|
||||
error.not_acceptable_media_type=不支持的媒体类型
|
||||
error.method_not_allowed=不支持的请求方法
|
||||
error.duplicate_data=重复的数据
|
||||
error.data_error=数据错误
|
||||
@@ -35,7 +35,6 @@
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -97,5 +96,10 @@
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.hibernate.validator</groupId>
|
||||
<artifactId>hibernate-validator</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -8,7 +8,6 @@ import com.alibaba.fastjson.parser.JSONToken;
|
||||
import com.alibaba.fastjson.parser.deserializer.ObjectDeserializer;
|
||||
import com.alibaba.fastjson.serializer.JSONSerializable;
|
||||
import com.alibaba.fastjson.serializer.JSONSerializer;
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -21,6 +20,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hswebframework.web.exception.ValidationException;
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@@ -118,7 +118,7 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
}
|
||||
|
||||
/**
|
||||
* 枚举选项的描述,对一个选项进行详细的描述有时候是必要的.默认值为{@link this#getText()}
|
||||
* 枚举选项的描述,对一个选项进行详细的描述有时候是必要的.默认值为{@link EnumDict#getText()}
|
||||
*
|
||||
* @return 描述
|
||||
*/
|
||||
@@ -127,7 +127,6 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 从指定的枚举类中查找想要的枚举,并返回一个{@link Optional},如果未找到,则返回一个{@link Optional#empty()}
|
||||
*
|
||||
@@ -150,8 +149,8 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
static <T extends Enum & EnumDict> List<T> findList(Class<T> type, Predicate<T> predicate) {
|
||||
if (type.isEnum()) {
|
||||
return Arrays.stream(type.getEnumConstants())
|
||||
.filter(predicate)
|
||||
.collect(Collectors.toList());
|
||||
.filter(predicate)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -159,16 +158,18 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
/**
|
||||
* 根据枚举的{@link EnumDict#getValue()}来查找.
|
||||
*
|
||||
* @see this#find(Class, Predicate)
|
||||
* @see EnumDict#find(Class, Predicate)
|
||||
*/
|
||||
static <T extends Enum & EnumDict<?>> Optional<T> findByValue(Class<T> type, Object value) {
|
||||
return find(type, e -> e.getValue() == value || e.getValue().equals(value) || String.valueOf(e.getValue()).equalsIgnoreCase(String.valueOf(value)));
|
||||
return find(type, e -> e.getValue() == value || e.getValue().equals(value) || String
|
||||
.valueOf(e.getValue())
|
||||
.equalsIgnoreCase(String.valueOf(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据枚举的{@link EnumDict#getText()} 来查找.
|
||||
*
|
||||
* @see this#find(Class, Predicate)
|
||||
* @see EnumDict#find(Class, Predicate)
|
||||
*/
|
||||
static <T extends Enum & EnumDict> Optional<T> findByText(Class<T> type, String text) {
|
||||
return find(type, e -> e.getText().equalsIgnoreCase(text));
|
||||
@@ -177,7 +178,7 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
/**
|
||||
* 根据枚举的{@link EnumDict#getValue()},{@link EnumDict#getText()}来查找.
|
||||
*
|
||||
* @see this#find(Class, Predicate)
|
||||
* @see EnumDict#find(Class, Predicate)
|
||||
*/
|
||||
static <T extends Enum & EnumDict> Optional<T> find(Class<T> type, Object target) {
|
||||
return find(type, v -> v.eq(target));
|
||||
@@ -203,8 +204,8 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
if (all.length >= 64) {
|
||||
List<T> list = Arrays.asList(t);
|
||||
return Arrays.stream(all)
|
||||
.map(EnumDict.class::cast)
|
||||
.anyMatch(list::contains);
|
||||
.map(EnumDict.class::cast)
|
||||
.anyMatch(list::contains);
|
||||
}
|
||||
return maskIn(toMask(t), target);
|
||||
}
|
||||
@@ -253,24 +254,32 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
|
||||
/**
|
||||
* @return 是否在序列化为json的时候, 将枚举以对象方式序列化
|
||||
* @see this#DEFAULT_WRITE_JSON_OBJECT
|
||||
* @see EnumDict#DEFAULT_WRITE_JSON_OBJECT
|
||||
*/
|
||||
default boolean isWriteJSONObjectEnabled() {
|
||||
return DEFAULT_WRITE_JSON_OBJECT;
|
||||
}
|
||||
|
||||
default String getI18nCode() {
|
||||
return getText();
|
||||
}
|
||||
|
||||
default String getI18nMessage(Locale locale) {
|
||||
return LocaleUtils.resolveMessage(getI18nCode(), locale, getText());
|
||||
}
|
||||
|
||||
/**
|
||||
* 当{@link this#isWriteJSONObjectEnabled()}返回true时,在序列化为json的时候,会写出此方法返回的对象
|
||||
* 当{@link EnumDict#isWriteJSONObjectEnabled()}返回true时,在序列化为json的时候,会写出此方法返回的对象
|
||||
*
|
||||
* @return 最终序列化的值
|
||||
* @see this#isWriteJSONObjectEnabled()
|
||||
* @see EnumDict#isWriteJSONObjectEnabled()
|
||||
*/
|
||||
@JsonValue
|
||||
default Object getWriteJSONObject() {
|
||||
if (isWriteJSONObjectEnabled()) {
|
||||
Map<String, Object> jsonObject = new HashMap<>();
|
||||
jsonObject.put("value", getValue());
|
||||
jsonObject.put("text", getText());
|
||||
jsonObject.put("text", getI18nMessage(LocaleUtils.current()));
|
||||
// jsonObject.put("index", index());
|
||||
// jsonObject.put("mask", getMask());
|
||||
return jsonObject;
|
||||
@@ -280,7 +289,7 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
}
|
||||
|
||||
@Override
|
||||
default void write(JSONSerializer jsonSerializer, Object o, Type type, int i) throws IOException {
|
||||
default void write(JSONSerializer jsonSerializer, Object o, Type type, int i) {
|
||||
if (isWriteJSONObjectEnabled()) {
|
||||
jsonSerializer.write(getWriteJSONObject());
|
||||
} else {
|
||||
@@ -295,7 +304,7 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
class EnumDictJSONDeserializer extends JsonDeserializer implements ObjectDeserializer {
|
||||
private Function<Object,Object> mapper;
|
||||
private Function<Object, Object> mapper;
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("all")
|
||||
@@ -324,8 +333,10 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
value = parser.parse();
|
||||
if (value instanceof Map) {
|
||||
return (T) EnumDict.find(((Class) type), ((Map) value).get("value"))
|
||||
.orElseGet(() ->
|
||||
EnumDict.find(((Class) type), ((Map) value).get("text")).orElse(null));
|
||||
.orElseGet(() ->
|
||||
EnumDict
|
||||
.find(((Class) type), ((Map) value).get("text"))
|
||||
.orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,11 +358,11 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
@SneakyThrows
|
||||
public Object deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
|
||||
JsonNode node = jp.getCodec().readTree(jp);
|
||||
if(mapper!=null){
|
||||
if(node.isTextual()){
|
||||
if (mapper != null) {
|
||||
if (node.isTextual()) {
|
||||
return mapper.apply(node.asText());
|
||||
}
|
||||
if(node.isNumber()){
|
||||
if (node.isNumber()) {
|
||||
return mapper.apply(node.asLong());
|
||||
}
|
||||
}
|
||||
@@ -364,19 +375,17 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
findPropertyType = BeanUtils.findPropertyType(currentName, currentValue.getClass());
|
||||
}
|
||||
Supplier<ValidationException> exceptionSupplier = () -> {
|
||||
List<Object> values= Stream.of(findPropertyType.getEnumConstants())
|
||||
List<Object> values = Stream
|
||||
.of(findPropertyType.getEnumConstants())
|
||||
.map(Enum.class::cast)
|
||||
.map(e->{
|
||||
if(e instanceof EnumDict){
|
||||
.map(e -> {
|
||||
if (e instanceof EnumDict) {
|
||||
return ((EnumDict) e).getValue();
|
||||
}
|
||||
return e.name();
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
return new ValidationException("参数[" + currentName + "]在选项中不存在",
|
||||
Arrays.asList(
|
||||
new ValidationException.Detail(currentName, "选项中不存在此值", values)
|
||||
));
|
||||
return new ValidationException(currentName,"validation.parameter_does_not_exist_in_enums", currentName);
|
||||
};
|
||||
if (EnumDict.class.isAssignableFrom(findPropertyType) && findPropertyType.isEnum()) {
|
||||
if (node.isObject()) {
|
||||
@@ -394,12 +403,11 @@ public interface EnumDict<V> extends JSONSerializable {
|
||||
.find(findPropertyType, node.textValue())
|
||||
.orElseThrow(exceptionSupplier);
|
||||
}
|
||||
throw new ValidationException("参数[" + currentName + "]在选项中不存在", Arrays.asList(
|
||||
new ValidationException.Detail(currentName, "选项中不存在此值", null)
|
||||
));
|
||||
return exceptionSupplier.get();
|
||||
}
|
||||
if (findPropertyType.isEnum()) {
|
||||
return Stream.of(findPropertyType.getEnumConstants())
|
||||
return Stream
|
||||
.of(findPropertyType.getEnumConstants())
|
||||
.filter(o -> {
|
||||
if (node.isTextual()) {
|
||||
return node.textValue().equalsIgnoreCase(((Enum) o).name());
|
||||
|
||||
@@ -26,12 +26,11 @@ import lombok.Getter;
|
||||
* @author zhouhao
|
||||
* @since 2.0
|
||||
*/
|
||||
public class BusinessException extends RuntimeException {
|
||||
public class BusinessException extends I18nSupportException {
|
||||
private static final long serialVersionUID = 5441923856899380112L;
|
||||
|
||||
@Getter
|
||||
private int status = 500;
|
||||
|
||||
@Getter
|
||||
private String code;
|
||||
|
||||
@@ -39,20 +38,21 @@ public class BusinessException extends RuntimeException {
|
||||
this(message, 500);
|
||||
}
|
||||
|
||||
public BusinessException(String message, int status, Object... args) {
|
||||
this(message, null, status, args);
|
||||
}
|
||||
|
||||
public BusinessException(String message, String code) {
|
||||
this(message, code, 500);
|
||||
}
|
||||
|
||||
public BusinessException(String message, String code, int status) {
|
||||
super(message);
|
||||
|
||||
public BusinessException(String message, String code, int status, Object... args) {
|
||||
super(message, args);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public BusinessException(String message, int status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public BusinessException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.hswebframework.web.exception;
|
||||
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
|
||||
@Getter
|
||||
@Setter(AccessLevel.PROTECTED)
|
||||
public class I18nSupportException extends RuntimeException {
|
||||
private String code;
|
||||
private Object[] args;
|
||||
|
||||
protected I18nSupportException() {
|
||||
|
||||
}
|
||||
|
||||
public I18nSupportException(String code, Object... args) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
this.args = args;
|
||||
}
|
||||
|
||||
public I18nSupportException(String code, Throwable cause, Object... args) {
|
||||
super(code, cause);
|
||||
this.args = args;
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage() {
|
||||
return super.getMessage() != null ? super.getMessage() : getLocalizedMessage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLocalizedMessage() {
|
||||
return LocaleUtils.resolveMessage(code, args);
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,11 @@ import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
public class NotFoundException extends BusinessException {
|
||||
public NotFoundException(String message) {
|
||||
super(message, 404);
|
||||
public NotFoundException(String message, Object... args) {
|
||||
super(message, 404, args);
|
||||
}
|
||||
|
||||
public NotFoundException() {
|
||||
this("记录不存在");
|
||||
this("error.not_found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
|
||||
import javax.validation.ConstraintViolation;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class ValidationException extends BusinessException {
|
||||
public class ValidationException extends I18nSupportException {
|
||||
|
||||
private static final boolean propertyI18nEnabled = Boolean.getBoolean("i18n.validation.property.enabled");
|
||||
|
||||
private List<Detail> details;
|
||||
|
||||
@@ -24,25 +24,43 @@ public class ValidationException extends BusinessException {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ValidationException(String property, String message) {
|
||||
this(message, Collections.singletonList(new Detail(property, message, null)));
|
||||
public ValidationException(String property, String message, Object... args) {
|
||||
this(message, Collections.singletonList(new Detail(property, message, null)), args);
|
||||
}
|
||||
|
||||
public ValidationException(String message, List<Detail> details) {
|
||||
super(message);
|
||||
public ValidationException(String message, List<Detail> details, Object... args) {
|
||||
super(message, 400, args);
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public ValidationException(String message, Set<? extends ConstraintViolation<?>> violations) {
|
||||
super(message);
|
||||
if (null != violations && !violations.isEmpty()) {
|
||||
details = new ArrayList<>();
|
||||
for (ConstraintViolation<?> violation : violations) {
|
||||
details.add(new Detail(violation.getPropertyPath().toString(), violation.getMessage(), null));
|
||||
}
|
||||
for (Detail detail : this.details) {
|
||||
detail.translateI18n(args);
|
||||
}
|
||||
}
|
||||
|
||||
public ValidationException(Set<? extends ConstraintViolation<?>> violations) {
|
||||
ConstraintViolation<?> first = violations.iterator().next();
|
||||
if (Objects.equals(first.getMessageTemplate(), first.getMessage())) {
|
||||
//模版和消息相同,说明是自定义的message,而不是已经通过i18n获取的.
|
||||
setCode(first.getMessage());
|
||||
} else {
|
||||
setCode("validation.property_validate_failed");
|
||||
}
|
||||
String property = first.getPropertyPath().toString();
|
||||
|
||||
//{0} 属性 ,{1} 验证消息
|
||||
//property也支持国际化?
|
||||
String resolveMessage = propertyI18nEnabled ?
|
||||
LocaleUtils.resolveMessage(first.getRootBeanClass().getName() + "." + property, property)
|
||||
: property;
|
||||
|
||||
setArgs(new Object[]{resolveMessage, first.getMessage()});
|
||||
|
||||
details = new ArrayList<>(violations.size());
|
||||
for (ConstraintViolation<?> violation : violations) {
|
||||
details.add(new Detail(violation.getPropertyPath().toString(), violation.getMessage(), null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@AllArgsConstructor
|
||||
@@ -55,5 +73,11 @@ public class ValidationException extends BusinessException {
|
||||
|
||||
@Schema(description = "详情")
|
||||
Object detail;
|
||||
|
||||
public void translateI18n(Object... args) {
|
||||
if (message.contains(".")) {
|
||||
message = LocaleUtils.resolveMessage(message, message, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.hswebframework.web.i18n;
|
||||
|
||||
import org.hibernate.validator.spi.messageinterpolation.LocaleResolver;
|
||||
import org.hibernate.validator.spi.messageinterpolation.LocaleResolverContext;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class ContextLocaleResolver implements LocaleResolver {
|
||||
@Override
|
||||
public Locale resolve(LocaleResolverContext context) {
|
||||
return LocaleUtils.current();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package org.hswebframework.web.i18n;
|
||||
|
||||
import org.hswebframework.web.exception.I18nSupportException;
|
||||
import org.springframework.context.MessageSource;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Signal;
|
||||
import reactor.core.publisher.SignalType;
|
||||
import reactor.util.context.Context;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* 用于进行国际化消息转换
|
||||
*
|
||||
* @author zhouhao
|
||||
* @since 4.0.11
|
||||
*/
|
||||
public class LocaleUtils {
|
||||
|
||||
public static final Locale DEFAULT_LOCALE = Locale.getDefault();
|
||||
|
||||
private static final ThreadLocal<Locale> CONTEXT_THREAD_LOCAL = new ThreadLocal<>();
|
||||
|
||||
static MessageSource messageSource = UnsupportedMessageSource.instance();
|
||||
|
||||
/**
|
||||
* 获取当前的语言地区,如果没有设置则返回系统默认语言
|
||||
*
|
||||
* @return Locale
|
||||
*/
|
||||
public static Locale current() {
|
||||
Locale locale = CONTEXT_THREAD_LOCAL.get();
|
||||
if (locale == null) {
|
||||
locale = DEFAULT_LOCALE;
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定的语言环境中执行函数,<b>只能</b>在非响应式同步操作时使用,如:转换实体类中某些属性的国际化消息。
|
||||
* <p>
|
||||
* 在函数的逻辑中可以通过{@link LocaleUtils#current()}来获取当前语言.
|
||||
*
|
||||
* @param data 参数
|
||||
* @param locale 语言地区
|
||||
* @param mapper 函数
|
||||
* @param <T> 参数类型
|
||||
* @param <R> 函数返回类型
|
||||
* @return 返回值
|
||||
*/
|
||||
public static <T, R> R doWith(T data, Locale locale, BiFunction<T, Locale, R> mapper) {
|
||||
try {
|
||||
CONTEXT_THREAD_LOCAL.set(locale);
|
||||
return mapper.apply(data, locale);
|
||||
} finally {
|
||||
CONTEXT_THREAD_LOCAL.remove();
|
||||
}
|
||||
}
|
||||
|
||||
public static Function<Context, Context> useLocale(Locale locale) {
|
||||
return ctx -> ctx.put(Locale.class, locale);
|
||||
}
|
||||
|
||||
public static void doWith(Locale locale, Consumer<Locale> consumer) {
|
||||
try {
|
||||
CONTEXT_THREAD_LOCAL.set(locale);
|
||||
consumer.accept(locale);
|
||||
} finally {
|
||||
CONTEXT_THREAD_LOCAL.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式方式获取当前语言地区
|
||||
*
|
||||
* @return 语言地区
|
||||
*/
|
||||
@SuppressWarnings("all")
|
||||
public static Mono<Locale> currentReactive() {
|
||||
return Mono
|
||||
.subscriberContext()
|
||||
.map(ctx -> ctx.getOrDefault(Locale.class, DEFAULT_LOCALE));
|
||||
}
|
||||
|
||||
public static <T> void onNext(Signal<T> signal, BiConsumer<T, Locale> consumer) {
|
||||
if (signal.getType() != SignalType.ON_NEXT) {
|
||||
return;
|
||||
}
|
||||
Locale locale = signal.getContext().getOrDefault(Locale.class, DEFAULT_LOCALE);
|
||||
|
||||
doWith(locale, l -> consumer.accept(signal.get(), l));
|
||||
|
||||
}
|
||||
|
||||
public static <S extends I18nSupportException, R> Mono<R> resolveThrowable(S source,
|
||||
BiFunction<S, String, R> mapper) {
|
||||
return resolveThrowable(messageSource, source, mapper);
|
||||
}
|
||||
|
||||
public static <S extends I18nSupportException, R> Mono<R> resolveThrowable(MessageSource messageSource,
|
||||
S source,
|
||||
BiFunction<S, String, R> mapper) {
|
||||
return doWithReactive(messageSource, source, I18nSupportException::getCode, mapper, source.getArgs());
|
||||
}
|
||||
|
||||
public static <S extends Throwable, R> Mono<R> resolveThrowable(S source,
|
||||
BiFunction<S, String, R> mapper,
|
||||
Object... args) {
|
||||
return resolveThrowable(messageSource, source, mapper, args);
|
||||
}
|
||||
|
||||
public static <S extends Throwable, R> Mono<R> resolveThrowable(MessageSource messageSource,
|
||||
S source,
|
||||
BiFunction<S, String, R> mapper,
|
||||
Object... args) {
|
||||
return doWithReactive(messageSource, source, Throwable::getMessage, mapper, args);
|
||||
}
|
||||
|
||||
public static <S, R> Mono<R> doWithReactive(S source,
|
||||
Function<S, String> message,
|
||||
BiFunction<S, String, R> mapper,
|
||||
Object... args) {
|
||||
return doWithReactive(messageSource, source, message, mapper, args);
|
||||
}
|
||||
|
||||
public static <S, R> Mono<R> doWithReactive(MessageSource messageSource,
|
||||
S source,
|
||||
Function<S, String> message,
|
||||
BiFunction<S, String, R> mapper,
|
||||
Object... args) {
|
||||
return currentReactive()
|
||||
.map(locale -> {
|
||||
String msg = message.apply(source);
|
||||
String newMsg = resolveMessage(messageSource, locale, msg, msg, args);
|
||||
return mapper.apply(source, newMsg);
|
||||
});
|
||||
}
|
||||
|
||||
public static Mono<String> resolveMessageReactive(MessageSource messageSource,
|
||||
String code,
|
||||
Object... args) {
|
||||
return currentReactive()
|
||||
.map(locale -> resolveMessage(messageSource, locale, code, code, args));
|
||||
}
|
||||
|
||||
public static String resolveMessage(String code,
|
||||
Locale locale,
|
||||
String defaultMessage,
|
||||
Object... args) {
|
||||
return resolveMessage(messageSource, locale, code, defaultMessage, args);
|
||||
}
|
||||
|
||||
public static String resolveMessage(MessageSource messageSource,
|
||||
Locale locale,
|
||||
String code,
|
||||
String defaultMessage,
|
||||
Object... args) {
|
||||
return messageSource.getMessage(code, args, defaultMessage, locale);
|
||||
}
|
||||
|
||||
public static String resolveMessage(String code, Object... args) {
|
||||
return resolveMessage(messageSource, current(), code, code, args);
|
||||
}
|
||||
|
||||
public static String resolveMessage(String code,
|
||||
String defaultMessage,
|
||||
Object... args) {
|
||||
return resolveMessage(messageSource, current(), code, defaultMessage, args);
|
||||
}
|
||||
|
||||
public static String resolveMessage(MessageSource messageSource,
|
||||
String code,
|
||||
String defaultMessage,
|
||||
Object... args) {
|
||||
return resolveMessage(messageSource, current(), code, defaultMessage, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.hswebframework.web.i18n;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
|
||||
public class MessageSourceInitializer {
|
||||
|
||||
public static void init(MessageSource messageSource) {
|
||||
if (LocaleUtils.messageSource == null || LocaleUtils.messageSource instanceof UnsupportedMessageSource) {
|
||||
LocaleUtils.messageSource = messageSource;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.hswebframework.web.i18n;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.context.NoSuchMessageException;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class UnsupportedMessageSource implements MessageSource {
|
||||
|
||||
private static final UnsupportedMessageSource INSTANCE = new UnsupportedMessageSource();
|
||||
|
||||
public static MessageSource instance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage(String code, Object[] args, String defaultMessage, Locale locale) {
|
||||
return defaultMessage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage(String code, Object[] args, Locale locale) throws NoSuchMessageException {
|
||||
return code;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException {
|
||||
return resolvable.getDefaultMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.hswebframework.web.i18n;
|
||||
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public class WebFluxLocaleFilter implements WebFilter {
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<Void> filter(@NonNull ServerWebExchange exchange, WebFilterChain chain) {
|
||||
return chain
|
||||
.filter(exchange)
|
||||
.subscriberContext(LocaleUtils.useLocale(getLocaleContext(exchange)));
|
||||
}
|
||||
|
||||
public Locale getLocaleContext(ServerWebExchange exchange) {
|
||||
String lang = exchange.getRequest()
|
||||
.getQueryParams()
|
||||
.getFirst(":lang");
|
||||
if (StringUtils.hasText(lang)) {
|
||||
return Locale.forLanguageTag(lang);
|
||||
}
|
||||
Locale locale = exchange.getLocaleContext().getLocale();
|
||||
if (locale == null) {
|
||||
return Locale.getDefault();
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package org.hswebframework.web.validator;
|
||||
|
||||
import org.hibernate.validator.BaseHibernateValidatorConfiguration;
|
||||
import org.hswebframework.web.exception.ValidationException;
|
||||
import org.hswebframework.web.i18n.ContextLocaleResolver;
|
||||
|
||||
import javax.el.ExpressionFactory;
|
||||
import javax.validation.ConstraintViolation;
|
||||
import javax.validation.Validation;
|
||||
import javax.validation.Validator;
|
||||
import javax.validation.ValidatorFactory;
|
||||
import javax.validation.*;
|
||||
import java.util.Set;
|
||||
|
||||
public final class ValidatorUtils {
|
||||
@@ -19,17 +17,26 @@ public final class ValidatorUtils {
|
||||
public static Validator getValidator() {
|
||||
if (validator == null) {
|
||||
synchronized (ValidatorUtils.class) {
|
||||
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
|
||||
Configuration<?> configuration = Validation
|
||||
.byDefaultProvider()
|
||||
.configure();
|
||||
configuration.addProperty(BaseHibernateValidatorConfiguration.LOCALE_RESOLVER_CLASSNAME,
|
||||
ContextLocaleResolver.class.getName());
|
||||
configuration.messageInterpolator(configuration.getDefaultMessageInterpolator());
|
||||
|
||||
ValidatorFactory factory = configuration.buildValidatorFactory();
|
||||
|
||||
return validator = factory.getValidator();
|
||||
}
|
||||
}
|
||||
return validator;
|
||||
}
|
||||
|
||||
@SuppressWarnings("all")
|
||||
public static <T> T tryValidate(T bean, Class... group) {
|
||||
Set<ConstraintViolation<T>> violations = getValidator().validate(bean, group);
|
||||
if (!violations.isEmpty()) {
|
||||
throw new ValidationException(violations.iterator().next().getMessage(), violations);
|
||||
throw new ValidationException(violations);
|
||||
}
|
||||
|
||||
return bean;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
error.not_found=The data does not exist
|
||||
error.cant_create_instance=Unable to create instance:{0}
|
||||
validation.parameter_does_not_exist_in_enums=Parameter {0} does not exist in option
|
||||
validation.property_validate_failed={0} {1}
|
||||
@@ -0,0 +1,5 @@
|
||||
error.not_found=数据不存在
|
||||
error.cant_create_instance=无法创建实例:{0}
|
||||
|
||||
validation.parameter_does_not_exist_in_enums=参数[{0}]在选择中不存在
|
||||
validation.property_validate_failed={0}{1}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.hswebframework.web.validator;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.hswebframework.web.exception.ValidationException;
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
import org.junit.Test;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class ValidatorUtilsTest {
|
||||
|
||||
|
||||
@Test
|
||||
public void test(){
|
||||
test(Locale.CHINA,"不能为空");
|
||||
test(Locale.ENGLISH,"must not be blank");
|
||||
}
|
||||
|
||||
public void test(Locale locale,String msg){
|
||||
try {
|
||||
LocaleUtils.doWith(locale,en->{
|
||||
ValidatorUtils.tryValidate(new TestEntity());
|
||||
});
|
||||
throw new IllegalStateException();
|
||||
}catch (ValidationException e){
|
||||
assertEquals(msg,e.getDetails().get(0).getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class TestEntity{
|
||||
|
||||
@NotBlank
|
||||
private String notBlank;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.hswebframework.web.starter.i18n;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.context.NoSuchMessageException;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
public class CompositeMessageSource implements MessageSource {
|
||||
|
||||
private final List<MessageSource> messageSources = new CopyOnWriteArrayList<>();
|
||||
|
||||
public void addMessageSources(Collection<MessageSource> source) {
|
||||
messageSources.addAll(source);
|
||||
}
|
||||
|
||||
public void addMessageSource(MessageSource source) {
|
||||
messageSources.add(source);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMessage(@Nonnull String code, Object[] args, String defaultMessage, @Nonnull Locale locale) {
|
||||
for (MessageSource messageSource : messageSources) {
|
||||
String result = messageSource.getMessage(code, args, null, locale);
|
||||
if (StringUtils.hasText(result)) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return defaultMessage;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nonnull
|
||||
public String getMessage(@Nonnull String code, Object[] args, @Nonnull Locale locale) throws NoSuchMessageException {
|
||||
for (MessageSource messageSource : messageSources) {
|
||||
try {
|
||||
String result = messageSource.getMessage(code, args, locale);
|
||||
if (StringUtils.hasText(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch (NoSuchMessageException ignore) {
|
||||
|
||||
}
|
||||
}
|
||||
throw new NoSuchMessageException(code, locale);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nonnull
|
||||
public String getMessage(@Nonnull MessageSourceResolvable resolvable, @Nonnull Locale locale) throws NoSuchMessageException {
|
||||
for (MessageSource messageSource : messageSources) {
|
||||
try {
|
||||
String result = messageSource.getMessage(resolvable, locale);
|
||||
if (StringUtils.hasText(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch (NoSuchMessageException ignore) {
|
||||
|
||||
}
|
||||
}
|
||||
String[] codes = resolvable.getCodes();
|
||||
throw new NoSuchMessageException(!ObjectUtils.isEmpty(codes) ? codes[codes.length - 1] : "", locale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.hswebframework.web.starter.i18n;
|
||||
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.hswebframework.web.i18n.MessageSourceInitializer;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureOrder;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
|
||||
@Slf4j
|
||||
public class I18nConfiguration {
|
||||
|
||||
@Bean
|
||||
@SneakyThrows
|
||||
public MessageSource autoResolveI18nMessageSource() {
|
||||
|
||||
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
|
||||
messageSource.setDefaultEncoding("UTF-8");
|
||||
Resource[] resources = new PathMatchingResourcePatternResolver().getResources("classpath*:i18n/**");
|
||||
|
||||
for (Resource resource : resources) {
|
||||
String path = resource.getURL().getPath();
|
||||
if (StringUtils.hasText(path) && (path.endsWith(".properties") || path.endsWith(".xml"))) {
|
||||
String name = path.substring(path.lastIndexOf("i18n"),path.indexOf("_"));
|
||||
|
||||
log.info("register i18n message resource {} -> {}", path,name);
|
||||
|
||||
messageSource.addBasenames(name);
|
||||
}
|
||||
}
|
||||
return messageSource;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public MessageSource compositeMessageSource(ObjectProvider<MessageSource> objectProvider) {
|
||||
CompositeMessageSource messageSource = new CompositeMessageSource();
|
||||
messageSource.addMessageSources(objectProvider.stream().collect(Collectors.toList()));
|
||||
MessageSourceInitializer.init(messageSource);
|
||||
return messageSource;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -51,6 +51,7 @@ public class CustomCodecsAutoConfiguration {
|
||||
return (configurer) -> {
|
||||
CodecConfigurer.DefaultCodecs defaults = configurer.defaultCodecs();
|
||||
defaults.jackson2JsonDecoder(new CustomJackson2JsonDecoder(entityFactory, objectMapper));
|
||||
defaults.jackson2JsonEncoder(new CustomJackson2jsonEncoder(objectMapper));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.fasterxml.jackson.databind.ObjectReader;
|
||||
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
|
||||
import com.fasterxml.jackson.databind.util.TokenBuffer;
|
||||
import org.hswebframework.web.api.crud.entity.EntityFactory;
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.core.ResolvableType;
|
||||
@@ -67,17 +68,23 @@ public class CustomJackson2JsonDecoder extends Jackson2CodecSupport implements H
|
||||
|
||||
ObjectReader reader = getObjectReader(elementType, hints);
|
||||
|
||||
return tokens.handle((tokenBuffer, sink) -> {
|
||||
try {
|
||||
Object value = reader.readValue(tokenBuffer.asParser(getObjectMapper()));
|
||||
logValue(value, hints);
|
||||
if (value != null) {
|
||||
sink.next(value);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
sink.error(processException(ex));
|
||||
}
|
||||
});
|
||||
return LocaleUtils
|
||||
.currentReactive()
|
||||
.flatMapMany(locale -> tokens
|
||||
.handle((tokenBuffer, sink) -> {
|
||||
LocaleUtils.doWith(locale, l -> {
|
||||
try {
|
||||
Object value = reader.readValue(tokenBuffer.asParser(getObjectMapper()));
|
||||
logValue(value, hints);
|
||||
if (value != null) {
|
||||
sink.next(value);
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
sink.error(processException(ex));
|
||||
}
|
||||
});
|
||||
|
||||
}));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -85,8 +92,15 @@ public class CustomJackson2JsonDecoder extends Jackson2CodecSupport implements H
|
||||
public Mono<Object> decodeToMono(@NonNull Publisher<DataBuffer> input, @NonNull ResolvableType elementType,
|
||||
@Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
|
||||
|
||||
return DataBufferUtils.join(input)
|
||||
.map(dataBuffer -> decode(dataBuffer, elementType, mimeType, hints));
|
||||
return LocaleUtils
|
||||
.currentReactive()
|
||||
.flatMap(locale -> DataBufferUtils
|
||||
.join(input)
|
||||
.map(dataBuffer -> LocaleUtils
|
||||
.doWith(dataBuffer,
|
||||
locale,
|
||||
(buf, l) -> decode(buf, elementType, mimeType, hints)))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package org.hswebframework.web.starter.jackson;
|
||||
|
||||
import org.hswebframework.web.i18n.LocaleUtils;
|
||||
import org.springframework.http.codec.json.Jackson2CodecSupport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.*;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonEncoding;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.util.ByteArrayBuilder;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectWriter;
|
||||
import com.fasterxml.jackson.databind.SequenceWriter;
|
||||
import com.fasterxml.jackson.databind.exc.InvalidDefinitionException;
|
||||
import org.reactivestreams.Publisher;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.core.codec.CodecException;
|
||||
import org.springframework.core.codec.EncodingException;
|
||||
import org.springframework.core.codec.Hints;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferFactory;
|
||||
import org.springframework.core.log.LogFormatUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.HttpMessageEncoder;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.MimeType;
|
||||
|
||||
/**
|
||||
* Base class providing support methods for Jackson 2.9 encoding. For non-streaming use
|
||||
* cases, {@link Flux} elements are collected into a {@link List} before serialization for
|
||||
* performance reason.
|
||||
*
|
||||
* @author Sebastien Deleuze
|
||||
* @author Arjen Poutsma
|
||||
* @since 5.0
|
||||
*/
|
||||
public class CustomJackson2jsonEncoder extends Jackson2CodecSupport implements HttpMessageEncoder<Object> {
|
||||
|
||||
private static final byte[] NEWLINE_SEPARATOR = {'\n'};
|
||||
|
||||
private static final Map<MediaType, byte[]> STREAM_SEPARATORS;
|
||||
|
||||
private static final Map<String, JsonEncoding> ENCODINGS;
|
||||
|
||||
static {
|
||||
STREAM_SEPARATORS = new HashMap<>(4);
|
||||
STREAM_SEPARATORS.put(MediaType.APPLICATION_STREAM_JSON, NEWLINE_SEPARATOR);
|
||||
STREAM_SEPARATORS.put(MediaType.parseMediaType("application/stream+x-jackson-smile"), new byte[0]);
|
||||
|
||||
ENCODINGS = new HashMap<>(JsonEncoding.values().length + 1);
|
||||
for (JsonEncoding encoding : JsonEncoding.values()) {
|
||||
ENCODINGS.put(encoding.getJavaName(), encoding);
|
||||
}
|
||||
ENCODINGS.put("US-ASCII", JsonEncoding.UTF8);
|
||||
}
|
||||
|
||||
|
||||
private final List<MediaType> streamingMediaTypes = new ArrayList<>(1);
|
||||
|
||||
|
||||
/**
|
||||
* Constructor with a Jackson {@link ObjectMapper} to use.
|
||||
*/
|
||||
protected CustomJackson2jsonEncoder(ObjectMapper mapper, MimeType... mimeTypes) {
|
||||
super(mapper, mimeTypes);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Configure "streaming" media types for which flushing should be performed
|
||||
* automatically vs at the end of the stream.
|
||||
* <p>By default this is set to {@link MediaType#APPLICATION_STREAM_JSON}.
|
||||
*
|
||||
* @param mediaTypes one or more media types to add to the list
|
||||
* @see HttpMessageEncoder#getStreamingMediaTypes()
|
||||
*/
|
||||
public void setStreamingMediaTypes(List<MediaType> mediaTypes) {
|
||||
this.streamingMediaTypes.clear();
|
||||
this.streamingMediaTypes.addAll(mediaTypes);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType) {
|
||||
Class<?> clazz = elementType.toClass();
|
||||
if (!supportsMimeType(mimeType)) {
|
||||
return false;
|
||||
}
|
||||
if (mimeType != null && mimeType.getCharset() != null) {
|
||||
Charset charset = mimeType.getCharset();
|
||||
if (!ENCODINGS.containsKey(charset.name())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (Object.class == clazz ||
|
||||
(!String.class.isAssignableFrom(elementType.resolve(clazz)) && getObjectMapper().canSerialize(clazz)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<DataBuffer> encode(Publisher<?> inputStream, DataBufferFactory bufferFactory,
|
||||
ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
|
||||
|
||||
Assert.notNull(inputStream, "'inputStream' must not be null");
|
||||
Assert.notNull(bufferFactory, "'bufferFactory' must not be null");
|
||||
Assert.notNull(elementType, "'elementType' must not be null");
|
||||
|
||||
return LocaleUtils
|
||||
.currentReactive()
|
||||
.flatMapMany(locale -> {
|
||||
if (inputStream instanceof Mono) {
|
||||
return Mono.from(inputStream)
|
||||
.map(value -> LocaleUtils
|
||||
.doWith(value, locale,
|
||||
((val, loc) ->
|
||||
encodeValue(val, bufferFactory, elementType, mimeType, hints)
|
||||
)
|
||||
))
|
||||
.flux();
|
||||
} else {
|
||||
byte[] separator = streamSeparator(mimeType);
|
||||
if (separator != null) { // streaming
|
||||
try {
|
||||
ObjectWriter writer = createObjectWriter(elementType, mimeType, hints);
|
||||
ByteArrayBuilder byteBuilder = new ByteArrayBuilder(writer
|
||||
.getFactory()
|
||||
._getBufferRecycler());
|
||||
JsonEncoding encoding = getJsonEncoding(mimeType);
|
||||
JsonGenerator generator = getObjectMapper()
|
||||
.getFactory()
|
||||
.createGenerator(byteBuilder, encoding);
|
||||
SequenceWriter sequenceWriter = writer.writeValues(generator);
|
||||
|
||||
return Flux
|
||||
.from(inputStream)
|
||||
.map(value -> LocaleUtils
|
||||
.doWith(value,
|
||||
locale,
|
||||
((val, loc) -> this
|
||||
.encodeStreamingValue(val,
|
||||
bufferFactory,
|
||||
hints,
|
||||
sequenceWriter,
|
||||
byteBuilder,
|
||||
separator)
|
||||
)
|
||||
))
|
||||
.doAfterTerminate(() -> {
|
||||
try {
|
||||
byteBuilder.release();
|
||||
generator.close();
|
||||
} catch (IOException ex) {
|
||||
logger.error("Could not close Encoder resources", ex);
|
||||
}
|
||||
});
|
||||
} catch (IOException ex) {
|
||||
return Flux.error(ex);
|
||||
}
|
||||
} else { // non-streaming
|
||||
ResolvableType listType = ResolvableType.forClassWithGenerics(List.class, elementType);
|
||||
return Flux.from(inputStream)
|
||||
.collectList()
|
||||
.map(value -> LocaleUtils
|
||||
.doWith(value, locale,
|
||||
((val, loc) ->
|
||||
encodeValue(val, bufferFactory, listType, mimeType, hints)
|
||||
)
|
||||
))
|
||||
.flux();
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory,
|
||||
ResolvableType valueType, @Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
|
||||
|
||||
ObjectWriter writer = createObjectWriter(valueType, mimeType, hints);
|
||||
ByteArrayBuilder byteBuilder = new ByteArrayBuilder(writer.getFactory()._getBufferRecycler());
|
||||
try {
|
||||
JsonEncoding encoding = getJsonEncoding(mimeType);
|
||||
|
||||
logValue(hints, value);
|
||||
|
||||
try (JsonGenerator generator = getObjectMapper().getFactory().createGenerator(byteBuilder, encoding)) {
|
||||
writer.writeValue(generator, value);
|
||||
generator.flush();
|
||||
} catch (InvalidDefinitionException ex) {
|
||||
throw new CodecException("Type definition error: " + ex.getType(), ex);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new EncodingException("JSON encoding error: " + ex.getOriginalMessage(), ex);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unexpected I/O error while writing to byte array builder", ex);
|
||||
}
|
||||
|
||||
byte[] bytes = byteBuilder.toByteArray();
|
||||
DataBuffer buffer = bufferFactory.allocateBuffer(bytes.length);
|
||||
buffer.write(bytes);
|
||||
|
||||
return buffer;
|
||||
} finally {
|
||||
byteBuilder.release();
|
||||
}
|
||||
}
|
||||
|
||||
private DataBuffer encodeStreamingValue(Object value, DataBufferFactory bufferFactory, @Nullable Map<String, Object> hints,
|
||||
SequenceWriter sequenceWriter, ByteArrayBuilder byteArrayBuilder, byte[] separator) {
|
||||
|
||||
logValue(hints, value);
|
||||
|
||||
try {
|
||||
sequenceWriter.write(value);
|
||||
sequenceWriter.flush();
|
||||
} catch (InvalidDefinitionException ex) {
|
||||
throw new CodecException("Type definition error: " + ex.getType(), ex);
|
||||
} catch (JsonProcessingException ex) {
|
||||
throw new EncodingException("JSON encoding error: " + ex.getOriginalMessage(), ex);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unexpected I/O error while writing to byte array builder", ex);
|
||||
}
|
||||
|
||||
byte[] bytes = byteArrayBuilder.toByteArray();
|
||||
byteArrayBuilder.reset();
|
||||
|
||||
int offset;
|
||||
int length;
|
||||
if (bytes.length > 0 && bytes[0] == ' ') {
|
||||
// SequenceWriter writes an unnecessary space in between values
|
||||
offset = 1;
|
||||
length = bytes.length - 1;
|
||||
} else {
|
||||
offset = 0;
|
||||
length = bytes.length;
|
||||
}
|
||||
DataBuffer buffer = bufferFactory.allocateBuffer(length + separator.length);
|
||||
buffer.write(bytes, offset, length);
|
||||
buffer.write(separator);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private void logValue(@Nullable Map<String, Object> hints, Object value) {
|
||||
if (!Hints.isLoggingSuppressed(hints)) {
|
||||
LogFormatUtils.traceDebug(logger, traceOn -> {
|
||||
String formatted = LogFormatUtils.formatValue(value, !traceOn);
|
||||
return Hints.getLogPrefix(hints) + "Encoding [" + formatted + "]";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private ObjectWriter createObjectWriter(ResolvableType valueType, @Nullable MimeType mimeType,
|
||||
@Nullable Map<String, Object> hints) {
|
||||
|
||||
JavaType javaType = getJavaType(valueType.getType(), null);
|
||||
Class<?> jsonView = (hints != null ? (Class<?>) hints.get(Jackson2CodecSupport.JSON_VIEW_HINT) : null);
|
||||
ObjectWriter writer = (jsonView != null ?
|
||||
getObjectMapper().writerWithView(jsonView) : getObjectMapper().writer());
|
||||
|
||||
if (javaType.isContainerType()) {
|
||||
writer = writer.forType(javaType);
|
||||
}
|
||||
|
||||
return customizeWriter(writer, mimeType, valueType, hints);
|
||||
}
|
||||
|
||||
protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType mimeType,
|
||||
ResolvableType elementType, @Nullable Map<String, Object> hints) {
|
||||
|
||||
return writer;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private byte[] streamSeparator(@Nullable MimeType mimeType) {
|
||||
for (MediaType streamingMediaType : this.streamingMediaTypes) {
|
||||
if (streamingMediaType.isCompatibleWith(mimeType)) {
|
||||
return STREAM_SEPARATORS.getOrDefault(streamingMediaType, NEWLINE_SEPARATOR);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the JSON encoding to use for the given mime type.
|
||||
*
|
||||
* @param mimeType the mime type as requested by the caller
|
||||
* @return the JSON encoding to use (never {@code null})
|
||||
* @since 5.0.5
|
||||
*/
|
||||
protected JsonEncoding getJsonEncoding(@Nullable MimeType mimeType) {
|
||||
if (mimeType != null && mimeType.getCharset() != null) {
|
||||
Charset charset = mimeType.getCharset();
|
||||
JsonEncoding result = ENCODINGS.get(charset.name());
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return JsonEncoding.UTF8;
|
||||
}
|
||||
|
||||
|
||||
// HttpMessageEncoder
|
||||
|
||||
@Override
|
||||
public List<MimeType> getEncodableMimeTypes() {
|
||||
return getMimeTypes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MediaType> getStreamingMediaTypes() {
|
||||
return Collections.unmodifiableList(this.streamingMediaTypes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getEncodeHints(@Nullable ResolvableType actualType, ResolvableType elementType,
|
||||
@Nullable MediaType mediaType, ServerHttpRequest request, ServerHttpResponse response) {
|
||||
|
||||
return (actualType != null ? getHints(actualType) : Hints.none());
|
||||
}
|
||||
|
||||
|
||||
// Jackson2CodecSupport
|
||||
|
||||
@Override
|
||||
protected <A extends Annotation> A getAnnotation(MethodParameter parameter, Class<A> annotType) {
|
||||
return parameter.getMethodAnnotation(annotType);
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@
|
||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||
org.hswebframework.web.starter.jackson.CustomCodecsAutoConfiguration,\
|
||||
org.hswebframework.web.starter.HswebAutoConfiguration,\
|
||||
org.hswebframework.web.starter.CorsAutoConfiguration
|
||||
org.hswebframework.web.starter.CorsAutoConfiguration,\
|
||||
org.hswebframework.web.starter.i18n.I18nConfiguration
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.hswebframework.web.starter.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hswebframework.web.dict.EnumDict;
|
||||
import org.hswebframework.web.i18n.MessageSourceInitializer;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.springframework.context.i18n.LocaleContext;
|
||||
import org.springframework.context.i18n.SimpleLocaleContext;
|
||||
import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
import org.springframework.core.ResolvableType;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public class CustomJackson2jsonEncoderTest {
|
||||
|
||||
|
||||
@Before
|
||||
public void init(){
|
||||
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
|
||||
messageSource.setDefaultEncoding("utf-8");
|
||||
messageSource.setBasenames("i18n.messages");
|
||||
MessageSourceInitializer.init(messageSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testI18n() {
|
||||
|
||||
doTest(new TestEntity(TestEnum.e1),Locale.forLanguageTag("en-US"),s->s.contains("Option1"));
|
||||
doTest(new TestEntity(TestEnum.e1),Locale.forLanguageTag("zh-CN"),s->s.contains("选项1"));
|
||||
|
||||
}
|
||||
|
||||
public void doTest(TestEntity entity, Locale locale, Predicate<String> verify){
|
||||
|
||||
CustomJackson2jsonEncoder encoder = new CustomJackson2jsonEncoder(new ObjectMapper());
|
||||
|
||||
encoder.encode(Mono.just(entity),
|
||||
new DefaultDataBufferFactory(),
|
||||
ResolvableType.forType(TestEntity.class),
|
||||
MediaType.APPLICATION_JSON,
|
||||
Collections.emptyMap())
|
||||
.as(DataBufferUtils::join)
|
||||
.map(buf -> buf.toString(StandardCharsets.UTF_8))
|
||||
.doOnNext(System.out::println)
|
||||
.subscriberContext(ctx->ctx.put(LocaleContext.class,new SimpleLocaleContext(locale)))
|
||||
.as(StepVerifier::create)
|
||||
.expectNextMatches(verify)
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public static class TestEntity {
|
||||
|
||||
private TestEnum testEnum;
|
||||
}
|
||||
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum TestEnum implements EnumDict<String> {
|
||||
e1("enum.e1"),
|
||||
e2("enum.e2");
|
||||
|
||||
private final String text;
|
||||
|
||||
@Override
|
||||
public String getValue() {
|
||||
return name();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
enum.e1=Option1
|
||||
enum.e2=Option2
|
||||
@@ -0,0 +1,2 @@
|
||||
enum.e1=选项1
|
||||
enum.e2=选项2
|
||||
@@ -75,7 +75,7 @@ public class PermissionProperties {
|
||||
.map(Permission::getActions)
|
||||
.orElseGet(Collections::emptySet));
|
||||
|
||||
throw new AccessDenyException("当前用户无权限:" + setting.getPermission() + "" +actions);
|
||||
throw new AccessDenyException(setting.getPermission(), actions);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class DefaultDimensionUserService extends GenericReactiveCrudService<Dime
|
||||
return this
|
||||
.publishEvent(entityPublisher, DimensionBindEvent::new)
|
||||
.as(super::insert)
|
||||
.onErrorMap(DuplicateKeyException.class, (err) -> new BusinessException("重复的绑定请求"));
|
||||
.onErrorMap(DuplicateKeyException.class, (err) -> new BusinessException("error.duplicate_key"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -82,13 +82,12 @@ public class DefaultReactiveUserService extends GenericReactiveCrudService<UserE
|
||||
.where(userEntity::getUsername)
|
||||
.fetch()
|
||||
.doOnNext(u -> {
|
||||
throw new org.hswebframework.web.exception.ValidationException("用户已存在");
|
||||
throw new org.hswebframework.web.exception.ValidationException("error.user_already_exists");
|
||||
})
|
||||
.then(Mono.just(userEntity))
|
||||
.doOnNext(e -> e.tryValidate(CreateGroup.class))
|
||||
.as(getRepository()::insert)
|
||||
.onErrorMap(DuplicateKeyException.class, e -> {
|
||||
throw new org.hswebframework.web.exception.ValidationException("用户已存在");
|
||||
throw new org.hswebframework.web.exception.ValidationException("error.user_already_exists");
|
||||
})
|
||||
.thenReturn(userEntity)
|
||||
.flatMap(user -> new UserCreatedEvent(user).publish(eventPublisher))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
error.duplicate_key=Duplicate Data
|
||||
error.user_already_exists=User already exists
|
||||
@@ -0,0 +1,2 @@
|
||||
error.duplicate_key=重复的请求
|
||||
error.user_already_exists=用户已存在
|
||||
Reference in New Issue
Block a user