diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/TwoFactor.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/TwoFactor.java index ae03f13ad..7805bb966 100644 --- a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/TwoFactor.java +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/annotation/TwoFactor.java @@ -55,5 +55,5 @@ public @interface TwoFactor { * @return 错误提示 * @since 3.0.6 */ - String message() default "需要进行双因子验证"; + String message() default "validation.verify_code_error"; } diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AccessDenyException.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AccessDenyException.java index 240ed822e..e9f118475 100644 --- a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AccessDenyException.java +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AccessDenyException.java @@ -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 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); } diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AuthenticationException.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AuthenticationException.java index dce94a7bf..767166d5e 100644 --- a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AuthenticationException.java +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/AuthenticationException.java @@ -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; diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/UnAuthorizedException.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/UnAuthorizedException.java index 025d72c72..75dd34fd6 100644 --- a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/UnAuthorizedException.java +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/exception/UnAuthorizedException.java @@ -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; diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/DefaultUserTokenManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/DefaultUserTokenManager.java index b7ba54b1c..05e61807f 100644 --- a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/DefaultUserTokenManager.java +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/DefaultUserTokenManager.java @@ -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(); }) diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenState.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenState.java index a35973dbc..6c9ac63af 100644 --- a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenState.java +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/TokenState.java @@ -13,30 +13,30 @@ public enum TokenState implements EnumDict { /** * 正常,有效 */ - 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; } diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManager.java b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManager.java index 4002379a1..a3def4cb4 100644 --- a/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManager.java +++ b/hsweb-authorization/hsweb-authorization-api/src/main/java/org/hswebframework/web/authorization/token/redis/RedisUserTokenManager.java @@ -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; }); diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_en_US.properties b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_en_US.properties new file mode 100644 index 000000000..9e1bcb7d1 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_en_US.properties @@ -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 \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_zh_CN.properties b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_zh_CN.properties new file mode 100644 index 000000000..a9bd62303 --- /dev/null +++ b/hsweb-authorization/hsweb-authorization-api/src/main/resources/i18n/authentication/messages_zh_CN.properties @@ -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=验证码错误 \ No newline at end of file diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinition.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinition.java index ca42b8184..d5c556493 100644 --- a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinition.java +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/define/DefaultBasicAuthorizeDefinition.java @@ -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; diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/twofactor/TwoFactorHandlerInterceptorAdapter.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/twofactor/TwoFactorHandlerInterceptorAdapter.java index d1eea30c6..c76076d5f 100644 --- a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/twofactor/TwoFactorHandlerInterceptorAdapter.java +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/twofactor/TwoFactorHandlerInterceptorAdapter.java @@ -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); diff --git a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizationController.java b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizationController.java index 4347c46f0..a8f553f04 100644 --- a/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizationController.java +++ b/hsweb-authorization/hsweb-authorization-basic/src/main/java/org/hswebframework/web/authorization/basic/web/AuthorizationController.java @@ -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 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); diff --git a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Exception.java b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Exception.java index fc48ad3e1..41d8a62a5 100644 --- a/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Exception.java +++ b/hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/OAuth2Exception.java @@ -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; } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/MapperEntityFactory.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/MapperEntityFactory.java index 39e156887..1d2667584 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/MapperEntityFactory.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/entity/factory/MapperEntityFactory.java @@ -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 diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/ValidateEventListener.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/ValidateEventListener.java index 88bb3fc2f..96b292136 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/ValidateEventListener.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/events/ValidateEventListener.java @@ -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 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)); } - } } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonErrorControllerAdvice.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonErrorControllerAdvice.java index 673442ebf..8e7c63347 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonErrorControllerAdvice.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonErrorControllerAdvice.java @@ -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> 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> handleException(UnsupportedOperationException e) { - return Mono.just(ResponseMessage.error("unsupported", e.getMessage())); + return LocaleUtils + .resolveThrowable(messageSource, e, (err, msg) -> (ResponseMessage.error(401, "unsupported", msg))); } @ExceptionHandler @ResponseStatus(HttpStatus.UNAUTHORIZED) public Mono> handleException(UnAuthorizedException e) { - return Mono.just(ResponseMessage.error(401, "unauthorized", e.getMessage()).result(e.getState())); + return LocaleUtils + .resolveThrowable(messageSource, e, (err, msg) -> (ResponseMessage.error(401, "unauthorized", msg) + .result(e.getState()))); } @ExceptionHandler @ResponseStatus(HttpStatus.FORBIDDEN) - public Mono> handleException(AccessDenyException e) { - return Mono.just(ResponseMessage.error(403, e.getCode(), e.getMessage())); + public Mono> handleException(AccessDenyException e) { + return LocaleUtils + .resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage.error(403, e.getCode(), e.getMessage())) + ; } @ExceptionHandler @ResponseStatus(HttpStatus.NOT_FOUND) - public Mono> handleException(NotFoundException e) { - return Mono.just(ResponseMessage.error(404, "not_found", e.getMessage())); + public Mono> handleException(NotFoundException e) { + return LocaleUtils + .resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage.error(404, "not_found", msg)) + ; } @ExceptionHandler @ResponseStatus(HttpStatus.BAD_REQUEST) public Mono>> handleException(ValidationException e) { - return Mono.just(ResponseMessage.>error(400, "illegal_argument", e.getMessage()) - .result(e.getDetails())); + return LocaleUtils + .resolveThrowable(messageSource, e, (err, msg) -> ResponseMessage + .>error(400, "illegal_argument",msg) + .result(e.getDetails())) + ; } @ExceptionHandler @ResponseStatus(HttpStatus.BAD_REQUEST) public Mono>> 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> 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> 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> 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> 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> 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> 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> 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> handleException(R2dbcDataIntegrityViolationException exception) { - if (exception.getMessage().contains("Duplicate")) { - return Mono.just(ResponseMessage.error("存在重复的数据")); + public Mono> 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)); } } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebFluxConfiguration.java b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebFluxConfiguration.java index af48922dd..1c814012f 100644 --- a/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebFluxConfiguration.java +++ b/hsweb-commons/hsweb-commons-crud/src/main/java/org/hswebframework/web/crud/web/CommonWebFluxConfiguration.java @@ -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(); + } + } diff --git a/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_en_US.properties b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_en_US.properties new file mode 100644 index 000000000..f35bfc889 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_en_US.properties @@ -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 \ No newline at end of file diff --git a/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_zh_CN.properties b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_zh_CN.properties new file mode 100644 index 000000000..9b9331846 --- /dev/null +++ b/hsweb-commons/hsweb-commons-crud/src/main/resources/i18n/commons/messages_zh_CN.properties @@ -0,0 +1,5 @@ +error.unsupported_media_type=不支持的请求类型 +error.not_acceptable_media_type=不支持的媒体类型 +error.method_not_allowed=不支持的请求方法 +error.duplicate_data=重复的数据 +error.data_error=数据错误 \ No newline at end of file diff --git a/hsweb-core/pom.xml b/hsweb-core/pom.xml index 25a891231..3e3287c15 100644 --- a/hsweb-core/pom.xml +++ b/hsweb-core/pom.xml @@ -35,7 +35,6 @@ org.springframework spring-web - @@ -97,5 +96,10 @@ 3.0.0 + + org.hibernate.validator + hibernate-validator + + \ No newline at end of file diff --git a/hsweb-core/src/main/java/org/hswebframework/web/dict/EnumDict.java b/hsweb-core/src/main/java/org/hswebframework/web/dict/EnumDict.java index e98330323..ac5494c0b 100644 --- a/hsweb-core/src/main/java/org/hswebframework/web/dict/EnumDict.java +++ b/hsweb-core/src/main/java/org/hswebframework/web/dict/EnumDict.java @@ -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 extends JSONSerializable { } /** - * 枚举选项的描述,对一个选项进行详细的描述有时候是必要的.默认值为{@link this#getText()} + * 枚举选项的描述,对一个选项进行详细的描述有时候是必要的.默认值为{@link EnumDict#getText()} * * @return 描述 */ @@ -127,7 +127,6 @@ public interface EnumDict extends JSONSerializable { } - /** * 从指定的枚举类中查找想要的枚举,并返回一个{@link Optional},如果未找到,则返回一个{@link Optional#empty()} * @@ -150,8 +149,8 @@ public interface EnumDict extends JSONSerializable { static List findList(Class type, Predicate 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 extends JSONSerializable { /** * 根据枚举的{@link EnumDict#getValue()}来查找. * - * @see this#find(Class, Predicate) + * @see EnumDict#find(Class, Predicate) */ static > Optional findByValue(Class 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 Optional findByText(Class type, String text) { return find(type, e -> e.getText().equalsIgnoreCase(text)); @@ -177,7 +178,7 @@ public interface EnumDict extends JSONSerializable { /** * 根据枚举的{@link EnumDict#getValue()},{@link EnumDict#getText()}来查找. * - * @see this#find(Class, Predicate) + * @see EnumDict#find(Class, Predicate) */ static Optional find(Class type, Object target) { return find(type, v -> v.eq(target)); @@ -203,8 +204,8 @@ public interface EnumDict extends JSONSerializable { if (all.length >= 64) { List 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 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 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 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 extends JSONSerializable { @AllArgsConstructor @NoArgsConstructor class EnumDictJSONDeserializer extends JsonDeserializer implements ObjectDeserializer { - private Function mapper; + private Function mapper; @Override @SuppressWarnings("all") @@ -324,8 +333,10 @@ public interface EnumDict 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 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 extends JSONSerializable { findPropertyType = BeanUtils.findPropertyType(currentName, currentValue.getClass()); } Supplier exceptionSupplier = () -> { - List values= Stream.of(findPropertyType.getEnumConstants()) + List 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 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()); diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/BusinessException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/BusinessException.java index 4dea9edb3..16a09d7a7 100644 --- a/hsweb-core/src/main/java/org/hswebframework/web/exception/BusinessException.java +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/BusinessException.java @@ -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); diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/I18nSupportException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/I18nSupportException.java new file mode 100644 index 000000000..ae2df4abe --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/I18nSupportException.java @@ -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); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/NotFoundException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/NotFoundException.java index 819481e79..b005303e4 100644 --- a/hsweb-core/src/main/java/org/hswebframework/web/exception/NotFoundException.java +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/NotFoundException.java @@ -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"); } } diff --git a/hsweb-core/src/main/java/org/hswebframework/web/exception/ValidationException.java b/hsweb-core/src/main/java/org/hswebframework/web/exception/ValidationException.java index ba0802a8b..be5936ff0 100644 --- a/hsweb-core/src/main/java/org/hswebframework/web/exception/ValidationException.java +++ b/hsweb-core/src/main/java/org/hswebframework/web/exception/ValidationException.java @@ -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 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 details) { - super(message); + public ValidationException(String message, List details, Object... args) { + super(message, 400, args); this.details = details; - } - - public ValidationException(String message, Set> 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> 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); + } + } } } diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/ContextLocaleResolver.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/ContextLocaleResolver.java new file mode 100644 index 000000000..cfc7b986f --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/ContextLocaleResolver.java @@ -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(); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/LocaleUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/LocaleUtils.java new file mode 100644 index 000000000..2a64e1d12 --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/LocaleUtils.java @@ -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 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; + } + + /** + * 在指定的语言环境中执行函数,只能在非响应式同步操作时使用,如:转换实体类中某些属性的国际化消息。 + *

+ * 在函数的逻辑中可以通过{@link LocaleUtils#current()}来获取当前语言. + * + * @param data 参数 + * @param locale 语言地区 + * @param mapper 函数 + * @param 参数类型 + * @param 函数返回类型 + * @return 返回值 + */ + public static R doWith(T data, Locale locale, BiFunction mapper) { + try { + CONTEXT_THREAD_LOCAL.set(locale); + return mapper.apply(data, locale); + } finally { + CONTEXT_THREAD_LOCAL.remove(); + } + } + + public static Function useLocale(Locale locale) { + return ctx -> ctx.put(Locale.class, locale); + } + + public static void doWith(Locale locale, Consumer consumer) { + try { + CONTEXT_THREAD_LOCAL.set(locale); + consumer.accept(locale); + } finally { + CONTEXT_THREAD_LOCAL.remove(); + } + } + + /** + * 响应式方式获取当前语言地区 + * + * @return 语言地区 + */ + @SuppressWarnings("all") + public static Mono currentReactive() { + return Mono + .subscriberContext() + .map(ctx -> ctx.getOrDefault(Locale.class, DEFAULT_LOCALE)); + } + + public static void onNext(Signal signal, BiConsumer 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 Mono resolveThrowable(S source, + BiFunction mapper) { + return resolveThrowable(messageSource, source, mapper); + } + + public static Mono resolveThrowable(MessageSource messageSource, + S source, + BiFunction mapper) { + return doWithReactive(messageSource, source, I18nSupportException::getCode, mapper, source.getArgs()); + } + + public static Mono resolveThrowable(S source, + BiFunction mapper, + Object... args) { + return resolveThrowable(messageSource, source, mapper, args); + } + + public static Mono resolveThrowable(MessageSource messageSource, + S source, + BiFunction mapper, + Object... args) { + return doWithReactive(messageSource, source, Throwable::getMessage, mapper, args); + } + + public static Mono doWithReactive(S source, + Function message, + BiFunction mapper, + Object... args) { + return doWithReactive(messageSource, source, message, mapper, args); + } + + public static Mono doWithReactive(MessageSource messageSource, + S source, + Function message, + BiFunction 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 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); + } + +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/MessageSourceInitializer.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/MessageSourceInitializer.java new file mode 100644 index 000000000..59072c03d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/MessageSourceInitializer.java @@ -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; + } + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/UnsupportedMessageSource.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/UnsupportedMessageSource.java new file mode 100644 index 000000000..8c040606d --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/UnsupportedMessageSource.java @@ -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(); + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/i18n/WebFluxLocaleFilter.java b/hsweb-core/src/main/java/org/hswebframework/web/i18n/WebFluxLocaleFilter.java new file mode 100644 index 000000000..748d7faee --- /dev/null +++ b/hsweb-core/src/main/java/org/hswebframework/web/i18n/WebFluxLocaleFilter.java @@ -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 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; + } +} diff --git a/hsweb-core/src/main/java/org/hswebframework/web/validator/ValidatorUtils.java b/hsweb-core/src/main/java/org/hswebframework/web/validator/ValidatorUtils.java index 13b9a16a0..e578f1b4d 100644 --- a/hsweb-core/src/main/java/org/hswebframework/web/validator/ValidatorUtils.java +++ b/hsweb-core/src/main/java/org/hswebframework/web/validator/ValidatorUtils.java @@ -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 tryValidate(T bean, Class... group) { Set> violations = getValidator().validate(bean, group); if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage(), violations); + throw new ValidationException(violations); } return bean; diff --git a/hsweb-core/src/main/resources/i18n/core/messages_en_US.properties b/hsweb-core/src/main/resources/i18n/core/messages_en_US.properties new file mode 100644 index 000000000..27ba5f25b --- /dev/null +++ b/hsweb-core/src/main/resources/i18n/core/messages_en_US.properties @@ -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} \ No newline at end of file diff --git a/hsweb-core/src/main/resources/i18n/core/messages_zh_CN.properties b/hsweb-core/src/main/resources/i18n/core/messages_zh_CN.properties new file mode 100644 index 000000000..181700341 --- /dev/null +++ b/hsweb-core/src/main/resources/i18n/core/messages_zh_CN.properties @@ -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} \ No newline at end of file diff --git a/hsweb-core/src/test/java/org/hswebframework/web/validator/ValidatorUtilsTest.java b/hsweb-core/src/test/java/org/hswebframework/web/validator/ValidatorUtilsTest.java new file mode 100644 index 000000000..07ac1d5b9 --- /dev/null +++ b/hsweb-core/src/test/java/org/hswebframework/web/validator/ValidatorUtilsTest.java @@ -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; + } +} \ No newline at end of file diff --git a/hsweb-starter/src/main/java/org/hswebframework/web/starter/i18n/CompositeMessageSource.java b/hsweb-starter/src/main/java/org/hswebframework/web/starter/i18n/CompositeMessageSource.java new file mode 100644 index 000000000..aa96385ac --- /dev/null +++ b/hsweb-starter/src/main/java/org/hswebframework/web/starter/i18n/CompositeMessageSource.java @@ -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 messageSources = new CopyOnWriteArrayList<>(); + + public void addMessageSources(Collection 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); + } +} diff --git a/hsweb-starter/src/main/java/org/hswebframework/web/starter/i18n/I18nConfiguration.java b/hsweb-starter/src/main/java/org/hswebframework/web/starter/i18n/I18nConfiguration.java new file mode 100644 index 000000000..c7b38a83e --- /dev/null +++ b/hsweb-starter/src/main/java/org/hswebframework/web/starter/i18n/I18nConfiguration.java @@ -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 objectProvider) { + CompositeMessageSource messageSource = new CompositeMessageSource(); + messageSource.addMessageSources(objectProvider.stream().collect(Collectors.toList())); + MessageSourceInitializer.init(messageSource); + return messageSource; + } + + +} diff --git a/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomCodecsAutoConfiguration.java b/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomCodecsAutoConfiguration.java index 8e1f57bd1..b6605d30f 100644 --- a/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomCodecsAutoConfiguration.java +++ b/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomCodecsAutoConfiguration.java @@ -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)); }; } diff --git a/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomJackson2JsonDecoder.java b/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomJackson2JsonDecoder.java index 0c7d3bc56..576817be3 100644 --- a/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomJackson2JsonDecoder.java +++ b/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomJackson2JsonDecoder.java @@ -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 decodeToMono(@NonNull Publisher input, @NonNull ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map 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 diff --git a/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomJackson2jsonEncoder.java b/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomJackson2jsonEncoder.java new file mode 100644 index 000000000..a999fb9f6 --- /dev/null +++ b/hsweb-starter/src/main/java/org/hswebframework/web/starter/jackson/CustomJackson2jsonEncoder.java @@ -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 { + + private static final byte[] NEWLINE_SEPARATOR = {'\n'}; + + private static final Map STREAM_SEPARATORS; + + private static final Map 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 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. + *

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 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 encode(Publisher inputStream, DataBufferFactory bufferFactory, + ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map 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 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 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 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 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 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 getEncodableMimeTypes() { + return getMimeTypes(); + } + + @Override + public List getStreamingMediaTypes() { + return Collections.unmodifiableList(this.streamingMediaTypes); + } + + @Override + public Map getEncodeHints(@Nullable ResolvableType actualType, ResolvableType elementType, + @Nullable MediaType mediaType, ServerHttpRequest request, ServerHttpResponse response) { + + return (actualType != null ? getHints(actualType) : Hints.none()); + } + + + // Jackson2CodecSupport + + @Override + protected A getAnnotation(MethodParameter parameter, Class annotType) { + return parameter.getMethodAnnotation(annotType); + } +} \ No newline at end of file diff --git a/hsweb-starter/src/main/resources/META-INF/spring.factories b/hsweb-starter/src/main/resources/META-INF/spring.factories index 8357d086b..43f2e42db 100644 --- a/hsweb-starter/src/main/resources/META-INF/spring.factories +++ b/hsweb-starter/src/main/resources/META-INF/spring.factories @@ -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 \ No newline at end of file +org.hswebframework.web.starter.CorsAutoConfiguration,\ +org.hswebframework.web.starter.i18n.I18nConfiguration \ No newline at end of file diff --git a/hsweb-starter/src/test/java/org/hswebframework/web/starter/jackson/CustomJackson2jsonEncoderTest.java b/hsweb-starter/src/test/java/org/hswebframework/web/starter/jackson/CustomJackson2jsonEncoderTest.java new file mode 100644 index 000000000..8fdbce763 --- /dev/null +++ b/hsweb-starter/src/test/java/org/hswebframework/web/starter/jackson/CustomJackson2jsonEncoderTest.java @@ -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 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 { + e1("enum.e1"), + e2("enum.e2"); + + private final String text; + + @Override + public String getValue() { + return name(); + } + + } +} \ No newline at end of file diff --git a/hsweb-starter/src/test/resources/i18n/messages_en_US.properties b/hsweb-starter/src/test/resources/i18n/messages_en_US.properties new file mode 100644 index 000000000..04bf44168 --- /dev/null +++ b/hsweb-starter/src/test/resources/i18n/messages_en_US.properties @@ -0,0 +1,2 @@ +enum.e1=Option1 +enum.e2=Option2 \ No newline at end of file diff --git a/hsweb-starter/src/test/resources/i18n/messages_zh_CN.properties b/hsweb-starter/src/test/resources/i18n/messages_zh_CN.properties new file mode 100644 index 000000000..2a8d80a92 --- /dev/null +++ b/hsweb-starter/src/test/resources/i18n/messages_zh_CN.properties @@ -0,0 +1,2 @@ +enum.e1=选项1 +enum.e2=选项2 \ No newline at end of file diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/configuration/PermissionProperties.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/configuration/PermissionProperties.java index 7f37d667f..e4765108d 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/configuration/PermissionProperties.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/configuration/PermissionProperties.java @@ -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); } }; diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java index 20b0263f0..f3c8d7e7c 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultDimensionUserService.java @@ -59,7 +59,7 @@ public class DefaultDimensionUserService extends GenericReactiveCrudService new BusinessException("重复的绑定请求")); + .onErrorMap(DuplicateKeyException.class, (err) -> new BusinessException("error.duplicate_key")); } @Override diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultReactiveUserService.java b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultReactiveUserService.java index 5fadaae38..43bfe03d8 100644 --- a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultReactiveUserService.java +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/java/org/hswebframework/web/system/authorization/defaults/service/DefaultReactiveUserService.java @@ -82,13 +82,12 @@ public class DefaultReactiveUserService extends GenericReactiveCrudService { - 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)) diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en_US.properties b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en_US.properties new file mode 100644 index 000000000..280214ac6 --- /dev/null +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_en_US.properties @@ -0,0 +1,2 @@ +error.duplicate_key=Duplicate Data +error.user_already_exists=User already exists \ No newline at end of file diff --git a/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh_CN.properties b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh_CN.properties new file mode 100644 index 000000000..16da067f5 --- /dev/null +++ b/hsweb-system/hsweb-system-authorization/hsweb-system-authorization-default/src/main/resources/i18n/authentication-default/messages_zh_CN.properties @@ -0,0 +1,2 @@ +error.duplicate_key=重复的请求 +error.user_already_exists=用户已存在 \ No newline at end of file