From 152781b05131e48bf6e94d71cc72dd54af52a3fb Mon Sep 17 00:00:00 2001 From: houzhongjian <houzhongyi@126.com> Date: 星期四, 10 四月 2025 14:13:29 +0800 Subject: [PATCH] 恢复iailab-framework --- iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeSerializer.java | 26 iailab-framework/iailab-common-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 2 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumValidator.java | 44 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkService.java | 36 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java | 36 iailab-framework/iailab-common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 5 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/package-info.java | 4 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/WebSocketProperties.java | 34 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java | 23 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/util/WebSocketFrameworkUtils.java | 67 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityRpcAutoConfiguration.java | 25 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClient.java | 83 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/util/TenantUtils.java | 93 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/package-info.java | 6 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortingField.java | 37 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java | 25 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/SwaggerProperties.java | 60 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/string/StrUtils.java | 90 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvWebAutoConfiguration.java | 32 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java | 25 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/filter/TraceFilter.java | 33 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataFilterInterceptor.java | 89 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java | 28 iailab-framework/iailab-common-protection/pom.xml | 47 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessage.java | 37 iailab-framework/iailab-common-monitor/pom.xml | 73 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ErrorCode.java | 32 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/config/IailabJacksonAutoConfiguration.java | 52 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/AreaConvert.java | 46 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/RateLimiterKeyResolver.java | 22 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/IoUtils.java | 28 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/IntArrayValuable.java | 15 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/ErrorCode.java | 46 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/package-info.java | 1 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DocumentEnum.java | 21 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/package-info.java | 17 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/RpcConstants.java | 17 iailab-framework/iailab-common/pom.xml | 159 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkService.java | 59 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/rpc/TenantRequestInterceptor.java | 25 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/AssertUtils.java | 101 iailab-framework/iailab-common-biz-tenant/pom.xml | 96 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java | 27 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/config/IailabIdempotentConfiguration.java | 46 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseMockitoUnitTest.java | 13 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/json/XssStringJsonDeserializer.java | 82 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/MyBatisUtils.java | 106 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/job/RedisPendingMessageResendJob.java | 100 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java | 47 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManager.java | 53 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/servlet/ServletUtils.java | 119 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoDict.java | 20 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/JsonConvert.java | 34 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/package-info.java | 6 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/security/TenantSecurityWebFilter.java | 117 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/NumberSerializer.java | 37 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java | 27 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogRpcAutoConfiguration.java | 18 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/rpc/LoginUserRequestInterceptor.java | 39 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CommonConstant.java | 406 + iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/redis/TenantRedisCacheManager.java | 46 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/entity/BaseEntity.java | 41 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/AuthorizeRequestsCustomizer.java | 37 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/handler/SelectSheetWriteHandler.java | 186 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ExceptionUtils.java | 53 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/SetUtils.java | 19 iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/package-info.java | 5 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/PageUtils.java | 67 iailab-framework/iailab-common-protection/src/test/java/com/iailab/framework/signature/core/ApiSignatureTest.java | 75 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/annotation/ApiAccessLog.java | 65 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/IailabXssAutoConfiguration.java | 63 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDsProcessor.java | 106 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java | 103 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServerException.java | 60 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/BaseService.java | 116 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/FileUtils.java | 84 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/IailabWebSocketAutoConfiguration.java | 177 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/StringListTypeHandler.java | 58 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/filter/DruidAdRemoveFilter.java | 38 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/package-info.java | 4 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/package-info.java | 6 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServiceException.java | 60 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvEnvironmentPostProcessor.java | 50 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/config/IailabBannerAutoConfiguration.java | 20 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/enums/DataSourceEnum.java | 22 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/MobileValidator.java | 25 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkServiceImpl.java | 33 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseRedisUnitTest.java | 32 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvRequestInterceptor.java | 24 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/DataContextHolder.java | 45 iailab-framework/iailab-common-job/pom.xml | 50 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/util/TracerFrameworkUtils.java | 46 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/core/DictFrameworkUtils.java | 96 iailab-framework/iailab-common-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 1 iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/core/package-info.java | 4 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/annotations/PreAuthenticated.java | 17 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/SqlInitializationTestConfiguration.java | 52 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/IailabSwaggerAutoConfiguration.java | 157 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/package-info.java | 12 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyWrapper.java | 68 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/JsoupXssCleaner.java | 64 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/handler/JsonWebSocketMessageHandler.java | 83 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/CommonResult.java | 128 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeSerializer.java | 26 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalResponseBodyHandler.java | 45 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkService.java | 19 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Mobile.java | 28 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/package-info.java | 4 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/package-info.java | 4 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/web/EnvWebFilter.java | 41 iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/annotation/Address.java | 30 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/NumberSerializer.java | 37 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/TenantProperties.java | 49 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java | 25 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/TelephoneValidator.java | 25 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDS.java | 25 iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 3 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/MapUtils.java | 68 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/package-info.java | 4 iailab-framework/iailab-common/src/test/java/com/iailab/framework/common/util/collection/CollectionUtilsTest.java | 64 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/config/IailabRateLimiterConfiguration.java | 55 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/UserTypeEnum.java | 39 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/JsonUtils.java | 202 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/LoginUser.java | 75 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumCollectionValidator.java | 42 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/annotation/Idempotent.java | 63 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/ServiceErrorCodeRange.java | 46 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQProducerAutoConfiguration.java | 31 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/package-info.java | 7 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/config/IailabApiSignatureAutoConfiguration.java | 28 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/RegexDesensitize.java | 38 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/DemoFilter.java | 35 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AccessDeniedHandlerImpl.java | 43 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java | 25 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/PasswordDesensitization.java | 25 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/package-info.java | 4 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/CommonStatusEnum.java | 46 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java | 42 iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/core/TimeoutRedisCacheManager.java | 86 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/SecurityProperties.java | 51 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageConsumer.java | 39 iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/config/package-info.java | 4 iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/DesensitizeTest.java | 100 iailab-framework/iailab-common-websocket/pom.xml | 73 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringExpressionUtils.java | 89 iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabRedisAutoConfiguration.java | 45 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/page/PageData.java | 43 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/handler/DesensitizationHandler.java | 21 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnore.java | 18 iailab-framework/iailab-common-env/src/main/resources/META-INF/spring.factories | 2 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmProcess.java | 25 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/dto/TreeLabelDTO.java | 20 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/GlobalConstants.java | 29 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/SliderDesensitize.java | 43 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/service/LogRecordServiceImpl.java | 87 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/listener/WebSocketMessageListener.java | 31 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/package-info.java | 4 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/TracerProperties.java | 14 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/filter/TokenAuthenticationFilter.java | 147 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbAndRedisUnitTest.java | 51 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/DictFormat.java | 22 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/EncryptTypeHandler.java | 75 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/package-info.java | 4 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/Dict.java | 32 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java | 46 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/aop/BizTraceAspect.java | 77 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageSender.java | 61 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/Constant.java | 161 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/package-info.java | 7 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmStatus.java | 25 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/util/ServiceExceptionUtil.java | 77 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnum.java | 35 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/BankCardDesensitize.java | 40 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/core/TranslateUtils.java | 37 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java | 26 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/monitor/TracerUtils.java | 30 iailab-framework/iailab-common-web/src/main/resources/banner.txt | 12 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/config/IailabRabbitMQAutoConfiguration.java | 28 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/DbTypeEnum.java | 84 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/config/IailabTranslateAutoConfiguration.java | 18 iailab-framework/pom.xml | 118 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/WebSocketAuthorizeRequestsCustomizer.java | 24 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Telephone.java | 28 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantBaseDO.java | 21 iailab-framework/iailab-common-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 2 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/IdCardDesensitization.java | 25 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java | 113 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/package-info.java | 4 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java | 40 iailab-framework/iailab-common-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 3 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/package-info.java | 4 iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring.factories | 2 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ObjectUtils.java | 63 iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/handler/AddressHandler.java | 19 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/util/ExcelUtils.java | 66 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IailabMybatisAutoConfiguration.java | 65 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyFilter.java | 31 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java | 103 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/JsonLongSetTypeHandler.java | 31 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantDatabaseInterceptor.java | 43 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/package-info.java | 4 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ExpressionRateLimiterKeyResolver.java | 64 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/UserRealName.java | 30 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/WebProperties.java | 66 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageSender.java | 57 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/message/JsonWebSocketMessage.java | 29 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/LocalDateTimeUtils.java | 309 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/http/HttpUtils.java | 385 + iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictAutoConfiguration.java | 18 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJobAspect.java | 66 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java | 22 iailab-framework/iailab-common-excel/src/test/java/com/iailab/framework/dict/core/util/DictFrameworkUtilsTest.java | 51 iailab-framework/iailab-common-mybatis/pom.xml | 95 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java | 48 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/PasswordDesensitize.java | 42 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java | 25 iailab-framework/iailab-common-excel/pom.xml | 82 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/aop/IdempotentAspect.java | 68 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalExceptionHandler.java | 356 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/config/IailabLock4jConfiguration.java | 18 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/aop/PreAuthenticatedAspect.java | 25 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/package-info.java | 5 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityAutoConfiguration.java | 118 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/MoneyConvert.java | 39 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java | 21 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/Lock4jRedisKeyConstants.java | 19 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/WebFilterOrderEnum.java | 36 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/EmailDesensitize.java | 36 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/CrudService.java | 35 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkServiceImpl.java | 33 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java | 78 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/BeanUtils.java | 69 iailab-framework/iailab-common-security/src/main/resources/assembly.xml | 50 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/LambdaQueryWrapperX.java | 135 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/IailabWebAutoConfiguration.java | 131 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvProperties.java | 22 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManagerImpl.java | 125 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/aop/RateLimiterAspect.java | 60 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/package-info.java | 4 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/LoginUserHandshakeInterceptor.java | 42 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/util/WebFrameworkUtils.java | 169 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssFilter.java | 52 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/dataobject/BaseDO.java | 56 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java | 92 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessage.java | 35 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogAutoConfiguration.java | 62 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringUtils.java | 24 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/IntegerListTypeHandler.java | 56 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java | 28 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessage.java | 34 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/function/ExcelColumnSelectFunction.java | 28 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/ExcelColumnSelect.java | 27 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/annotation/BizTrace.java | 42 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/IdCardDesensitize.java | 40 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/RandomUtils.java | 137 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/config/IailabDataSourceAutoConfiguration.java | 40 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/DictConvert.java | 72 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java | 38 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/DateUtils.java | 304 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkServiceImpl.java | 103 iailab-framework/iailab-common-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 3 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessage.java | 35 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/WebSocketMessageSender.java | 52 iailab-framework/iailab-common-test/pom.xml | 60 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/DefaultDBFieldHandler.java | 62 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/DefaultGroup.java | 19 iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheAutoConfiguration.java | 82 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/local/LocalWebSocketMessageSender.java | 20 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java | 27 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageConsumer.java | 30 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageResult.java | 41 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java | 40 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/redis/IdempotentRedisDAO.java | 41 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantAutoConfiguration.java | 160 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageSender.java | 62 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/message/AbstractRedisMessage.java | 29 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ConvertUtils.java | 79 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java | 31 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/XssCleaner.java | 16 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/context/EnvContextHolder.java | 39 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/util/EnvUtils.java | 56 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/MyBatisConfiguration.java | 84 iailab-framework/iailab-common-mq/pom.xml | 43 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJob.java | 14 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/CrudServiceImpl.java | 80 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/DataDS.java | 25 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQConsumerAutoConfiguration.java | 151 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkServiceImpl.java | 103 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkService.java | 19 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/package-info.java | 1 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvRpcAutoConfiguration.java | 46 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java | 23 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/package-info.java | 1 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/KeyValue.java | 22 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/NumberUtils.java | 64 iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/package-info.java | 4 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/AbstractWebSocketMessageSender.java | 104 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/MobileDesensitization.java | 26 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/MPJLambdaWrapperX.java | 313 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/util/SecurityFrameworkUtils.java | 142 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java | 27 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/package-info.java | 6 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringContextUtils.java | 55 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/package-info.java | 6 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CacheConstant.java | 108 iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabXxlJobAutoConfiguration.java | 47 iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/package-info.java | 6 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java | 63 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/enums/OperateTypeEnum.java | 51 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/BankCardDesensitization.java | 27 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/RedisTestConfiguration.java | 35 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabTracerAutoConfiguration.java | 55 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/core/package-info.java | 4 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/filter/ApiAccessLogFilter.java | 251 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageParam.java | 36 iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictRpcAutoConfiguration.java | 15 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/Group.java | 22 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/GlobalErrorCodeConstants.java | 42 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogConfiguration.java | 27 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageConsumer.java | 23 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortablePageParam.java | 19 iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbUnitTest.java | 43 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataScope.java | 36 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/ApiRequestFilter.java | 27 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/BaseServiceImpl.java | 219 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AuthenticationEntryPointImpl.java | 35 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoUser.java | 20 iailab-framework/iailab-common-env/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 2 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/package-info.java | 4 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/redis/ApiSignatureRedisDAO.java | 57 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/package-info.java | 6 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/core/BannerApplicationRunner.java | 28 iailab-framework/iailab-common-redis/pom.xml | 41 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/XssProperties.java | 29 iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/XxlJobProperties.java | 99 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java | 106 iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheProperties.java | 27 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/TerminalEnum.java | 40 iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClientFactory.java | 30 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/package-info.java | 7 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/MobileDesensitize.java | 40 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageSender.java | 67 iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabAsyncAutoConfiguration.java | 37 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DateIntervalEnum.java | 46 iailab-framework/iailab-common-web/pom.xml | 99 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/DefaultLockFailureStrategy.java | 21 iailab-framework/iailab-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 6 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/QueryWrapperX.java | 166 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java | 49 iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring.factories | 2 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/package-info.java | 4 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/annotation/ApiSignature.java | 59 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/AddGroup.java | 19 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/TenantContextHolder.java | 69 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeDeserializer.java | 27 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/UpdateGroup.java | 19 iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageConsumer.java | 28 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/package-info.java | 7 iailab-framework/iailab-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 2 iailab-framework/iailab-common-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 2 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/SqlConstants.java | 22 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/web/TenantContextWebFilter.java | 37 iailab-framework/iailab-common/src/main/java/com/fhs/trans/service/AutoTransable.java | 59 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaEnvironmentPostProcessor.java | 37 iailab-framework/iailab-common-security/pom.xml | 78 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java | 40 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/ArrayUtils.java | 58 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoBpm.java | 16 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/cache/CacheUtils.java | 49 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java | 22 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/package-info.java | 6 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java | 60 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/package-info.java | 6 iailab-framework/iailab-common-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java | 269 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/MoneyUtils.java | 131 iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 2 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/MybatisHandler.java | 45 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/LongListTypeHandler.java | 57 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/mapper/BaseMapperX.java | 225 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/annotation/RateLimiter.java | 62 iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/aop/ApiSignatureAspect.java | 169 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantRpcAutoConfiguration.java | 21 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java | 23 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/common/RoutingConstant.java | 22 iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabMetricsAutoConfiguration.java | 28 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabWebSecurityConfigurerAdapter.java | 217 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/dao/BaseDao.java | 21 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnoreAspect.java | 35 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/JdbcUtils.java | 61 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/validation/ValidationUtils.java | 55 iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java | 53 iailab-framework/iailab-common-env/pom.xml | 66 iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogRpcAutoConfiguration.java | 15 iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/CollectionUtils.java | 338 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/package-info.java | 8 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/annotation/DesensitizeBy.java | 32 iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/RedisMQTemplate.java | 87 iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/package-info.java | 4 iailab-framework/iailab-common-rpc/pom.xml | 47 iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssRequestWrapper.java | 92 400 files changed, 21,490 insertions(+), 0 deletions(-) diff --git a/iailab-framework/iailab-common-biz-tenant/pom.xml b/iailab-framework/iailab-common-biz-tenant/pom.xml new file mode 100644 index 0000000..3c4431e --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/pom.xml @@ -0,0 +1,96 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <artifactId>iailab-framework</artifactId> + <groupId>com.iailab</groupId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-biz-tenant</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>多租户</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-security</artifactId> + </dependency> + + <!-- DB 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-mybatis</artifactId> + </dependency> + + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-redis</artifactId> + </dependency> + <dependency> + <groupId>org.postgresql</groupId> + <artifactId>postgresql</artifactId> + </dependency> + + <!-- RPC 远程调用相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-rpc</artifactId> + <optional>true</optional> + </dependency> + + <!-- Job 定时任务相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-job</artifactId> + <optional>true</optional> + </dependency> + + <!-- 消息队列相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-mq</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.springframework.amqp</groupId> + <artifactId>spring-rabbit</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.apache.rocketmq</groupId> + <artifactId>rocketmq-spring-boot-starter</artifactId> + <optional>true</optional> + </dependency> + + <!-- Test 测试相关 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + </dependency> + + <!-- 工具类相关 --> + <dependency> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> + </dependency> + + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantAutoConfiguration.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantAutoConfiguration.java new file mode 100644 index 0000000..14060f8 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantAutoConfiguration.java @@ -0,0 +1,160 @@ +package com.iailab.framework.tenant.config; + +import com.baomidou.dynamic.datasource.processor.DsProcessor; +import com.baomidou.dynamic.datasource.processor.DsSpelExpressionProcessor; +import com.iailab.framework.common.enums.WebFilterOrderEnum; +import com.iailab.framework.mybatis.core.util.MyBatisUtils; +import com.iailab.framework.redis.config.IailabCacheProperties; +import com.iailab.framework.tenant.core.aop.TenantIgnoreAspect; +import com.iailab.framework.tenant.core.db.TenantDatabaseInterceptor; +import com.iailab.framework.tenant.core.db.dynamic.TenantDsProcessor; +import com.iailab.framework.tenant.core.job.TenantJobAspect; +import com.iailab.framework.tenant.core.mq.rabbitmq.TenantRabbitMQInitializer; +import com.iailab.framework.tenant.core.mq.redis.TenantRedisMessageInterceptor; +import com.iailab.framework.tenant.core.mq.rocketmq.TenantRocketMQInitializer; +import com.iailab.framework.tenant.core.redis.TenantRedisCacheManager; +import com.iailab.framework.tenant.core.security.TenantSecurityWebFilter; +import com.iailab.framework.tenant.core.service.TenantFrameworkService; +import com.iailab.framework.tenant.core.service.TenantFrameworkServiceImpl; +import com.iailab.framework.tenant.core.web.TenantContextWebFilter; +import com.iailab.framework.web.config.WebProperties; +import com.iailab.framework.web.core.handler.GlobalExceptionHandler; +import com.iailab.module.system.api.tenant.TenantApi; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.BatchStrategies; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Objects; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "iailab.tenant", value = "enable", matchIfMissing = true) // 允许使用 iailab.tenant.enable=false 禁用多租户 +@EnableConfigurationProperties(TenantProperties.class) +public class IailabTenantAutoConfiguration { + + @Bean + public TenantFrameworkService tenantFrameworkService(TenantApi tenantApi) { + return new TenantFrameworkServiceImpl(tenantApi); + } + + // ========== AOP ========== + + @Bean + public TenantIgnoreAspect tenantIgnoreAspect() { + return new TenantIgnoreAspect(); + } + + // ========== DB ========== + + @Bean + public TenantLineInnerInterceptor tenantLineInnerInterceptor(TenantProperties properties, + MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(properties)); + // 添加到 interceptor 中 + // 需要加在首个,主要是为了在分页插件前面。这个是 MyBatis Plus 的规定 + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + @Bean + public DsProcessor dsProcessor( +// TenantFrameworkService tenantFrameworkService, +// DataSource dataSource, +// DefaultDataSourceCreator dataSourceCreator + ) { +// TenantDsProcessor tenantDsProcessor = new TenantDsProcessor(tenantFrameworkService, dataSourceCreator); + TenantDsProcessor tenantDsProcessor = new TenantDsProcessor(); + tenantDsProcessor.setNextProcessor(new DsSpelExpressionProcessor()); + return tenantDsProcessor; + } + + // ========== WEB ========== + + @Bean + public FilterRegistrationBean<TenantContextWebFilter> tenantContextWebFilter() { + FilterRegistrationBean<TenantContextWebFilter> registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TenantContextWebFilter()); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_CONTEXT_FILTER); + return registrationBean; + } + + // ========== Security ========== + + @Bean + public FilterRegistrationBean<TenantSecurityWebFilter> tenantSecurityWebFilter(TenantProperties tenantProperties, + WebProperties webProperties, + GlobalExceptionHandler globalExceptionHandler, + TenantFrameworkService tenantFrameworkService) { + FilterRegistrationBean<TenantSecurityWebFilter> registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TenantSecurityWebFilter(tenantProperties, webProperties, + globalExceptionHandler, tenantFrameworkService)); + registrationBean.setOrder(WebFilterOrderEnum.TENANT_SECURITY_FILTER); + return registrationBean; + } + + // ========== Job ========== + + @Bean + @ConditionalOnClass(name = "com.xxl.job.core.handler.annotation.XxlJob") + public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) { + return new TenantJobAspect(tenantFrameworkService); + } + + // ========== MQ ========== + + /** + * 多租户 Redis 消息队列的配置类 + * + * 为什么要单独一个配置类呢?如果直接把 TenantRedisMessageInterceptor Bean 的初始化放外面,会报 RedisMessageInterceptor 类不存在的错误 + */ + @Configuration + @ConditionalOnClass(name = "com.iailab.framework.mq.redis.core.RedisMQTemplate") + public static class TenantRedisMQAutoConfiguration { + + @Bean + public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() { + return new TenantRedisMessageInterceptor(); + } + + } + + @Bean + @ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") + public TenantRabbitMQInitializer tenantRabbitMQInitializer() { + return new TenantRabbitMQInitializer(); + } + + @Bean + @ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate") + public TenantRocketMQInitializer tenantRocketMQInitializer() { + return new TenantRocketMQInitializer(); + } + + // ========== Redis ========== + + @Bean + @Primary // 引入租户时,tenantRedisCacheManager 为主 Bean + public RedisCacheManager tenantRedisCacheManager(RedisTemplate<String, Object> redisTemplate, + RedisCacheConfiguration redisCacheConfiguration, + IailabCacheProperties iailabCacheProperties, + TenantProperties tenantProperties) { + // 创建 RedisCacheWriter 对象 + RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, + BatchStrategies.scan(iailabCacheProperties.getRedisScanBatchSize())); + // 创建 TenantRedisCacheManager 对象 + return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches()); + } +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantRpcAutoConfiguration.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantRpcAutoConfiguration.java new file mode 100644 index 0000000..cfa5d68 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/IailabTenantRpcAutoConfiguration.java @@ -0,0 +1,21 @@ +package com.iailab.framework.tenant.config; + +import com.iailab.framework.tenant.core.rpc.TenantRequestInterceptor; +import com.iailab.module.system.api.tenant.TenantApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@AutoConfiguration +@ConditionalOnProperty(prefix = "iailab.tenant", value = "enable", matchIfMissing = true) // 允许使用 iailab.tenant.enable=false 禁用多租户 +@EnableFeignClients(clients = TenantApi.class) // 主要是引入相关的 API 服务 +public class IailabTenantRpcAutoConfiguration { + + @Bean + public TenantRequestInterceptor tenantRequestInterceptor() { + return new TenantRequestInterceptor(); + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/TenantProperties.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/TenantProperties.java new file mode 100644 index 0000000..4fc2ddf --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/config/TenantProperties.java @@ -0,0 +1,49 @@ +package com.iailab.framework.tenant.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.Collections; +import java.util.Set; + +/** + * 多租户配置 + * + * @author iailab + */ +@ConfigurationProperties(prefix = "iailab.tenant") +@Data +public class TenantProperties { + + /** + * 租户是否开启 + */ + private static final Boolean ENABLE_DEFAULT = true; + + /** + * 是否开启 + */ + private Boolean enable = ENABLE_DEFAULT; + + /** + * 需要忽略多租户的请求 + * + * 默认情况下,每个请求需要带上 tenant-id 的请求头。但是,部分请求是无需带上的,例如说短信回调、支付回调等 Open API! + */ + private Set<String> ignoreUrls = Collections.emptySet(); + + /** + * 需要忽略多租户的表 + * + * 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 + */ + private Set<String> ignoreTables = Collections.emptySet(); + + /** + * 需要忽略多租户的 Spring Cache 缓存 + * + * 即默认所有缓存都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟 + */ + private Set<String> ignoreCaches = Collections.emptySet(); + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnore.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnore.java new file mode 100644 index 0000000..adf4915 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnore.java @@ -0,0 +1,18 @@ +package com.iailab.framework.tenant.core.aop; + +import java.lang.annotation.*; + +/** + * 忽略租户,标记指定方法不进行租户的自动过滤 + * + * 注意,只有 DB 的场景会过滤,其它场景暂时不过滤: + * 1、Redis 场景:因为是基于 Key 实现多租户的能力,所以忽略没有意义,不像 DB 是一个 column 实现的 + * 2、MQ 场景:有点难以抉择,目前可以通过 Consumer 手动在消费的方法上,添加 @TenantIgnore 进行忽略 + * + * @author iailab + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface TenantIgnore { +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnoreAspect.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnoreAspect.java new file mode 100644 index 0000000..4814290 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/aop/TenantIgnoreAspect.java @@ -0,0 +1,35 @@ +package com.iailab.framework.tenant.core.aop; + +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.iailab.framework.tenant.core.util.TenantUtils; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +/** + * 忽略多租户的 Aspect,基于 {@link TenantIgnore} 注解实现,用于一些全局的逻辑。 + * 例如说,一个定时任务,读取所有数据,进行处理。 + * 又例如说,读取所有数据,进行缓存。 + * + * 整体逻辑的实现,和 {@link TenantUtils#executeIgnore(Runnable)} 需要保持一致 + * + * @author iailab + */ +@Aspect +@Slf4j +public class TenantIgnoreAspect { + + @Around("@annotation(tenantIgnore)") + public Object around(ProceedingJoinPoint joinPoint, TenantIgnore tenantIgnore) throws Throwable { + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setIgnore(true); + // 执行逻辑 + return joinPoint.proceed(); + } finally { + TenantContextHolder.setIgnore(oldIgnore); + } + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/DataContextHolder.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/DataContextHolder.java new file mode 100644 index 0000000..297ed1d --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/DataContextHolder.java @@ -0,0 +1,45 @@ +package com.iailab.framework.tenant.core.context; + +import com.alibaba.ttl.TransmittableThreadLocal; +import com.iailab.framework.common.enums.DocumentEnum; + +/** + * 数据源上下文 Holder + * + * @author iailab + */ +public class DataContextHolder { + + /** + * 数据源id + */ + private static final ThreadLocal<Long> DATA_SOURCE_ID = new TransmittableThreadLocal<>(); + + /** + * 数据源id + * + * @return 租户编号 + */ + public static Long getDataSourceId() { + return DATA_SOURCE_ID.get(); + } + + /** + * 数据源id。如果不存在,则抛出 NullPointerException 异常 + * + * @return 租户编号 + */ + public static Long getRequiredDataSourceId() { + Long dataSourceId = getDataSourceId(); + if (dataSourceId == null) { + throw new NullPointerException("DataContextHolder 不存在数据源id!可参考文档:" + + DocumentEnum.TENANT.getUrl()); + } + return dataSourceId; + } + + public static void setDataSourceId(Long dataSourceId) { + DATA_SOURCE_ID.set(dataSourceId); + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/TenantContextHolder.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/TenantContextHolder.java new file mode 100644 index 0000000..1407a84 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/context/TenantContextHolder.java @@ -0,0 +1,69 @@ +package com.iailab.framework.tenant.core.context; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.enums.DocumentEnum; +import com.alibaba.ttl.TransmittableThreadLocal; + +/** + * 多租户上下文 Holder + * + * @author iailab + */ +public class TenantContextHolder { + + /** + * 当前租户编号 + */ + private static final ThreadLocal<Long> TENANT_ID = new TransmittableThreadLocal<>(); + + /** + * 是否忽略租户 + */ + private static final ThreadLocal<Boolean> IGNORE = new TransmittableThreadLocal<>(); + + /** + * 获得租户编号 + * + * @return 租户编号 + */ + public static Long getTenantId() { + return TENANT_ID.get(); + } + + /** + * 获得租户编号。如果不存在,则抛出 NullPointerException 异常 + * + * @return 租户编号 + */ + public static Long getRequiredTenantId() { + Long tenantId = getTenantId(); + if (tenantId == null) { + throw new NullPointerException("TenantContextHolder 不存在租户编号!可参考文档:" + + DocumentEnum.TENANT.getUrl()); + } + return tenantId; + } + + public static void setTenantId(Long tenantId) { + TENANT_ID.set(tenantId); + } + + public static void setIgnore(Boolean ignore) { + IGNORE.set(ignore); + } + + /** + * 当前是否忽略租户 + * + * @return 是否忽略 + */ + public static boolean isIgnore() { + return Boolean.TRUE.equals(IGNORE.get()); + } + + public static void clear() { + TENANT_ID.remove(); + IGNORE.remove(); + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantBaseDO.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantBaseDO.java new file mode 100644 index 0000000..bcfa9a8 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantBaseDO.java @@ -0,0 +1,21 @@ +package com.iailab.framework.tenant.core.db; + +import com.iailab.framework.mybatis.core.dataobject.BaseDO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 拓展多租户的 BaseDO 基类 + * + * @author iailab + */ +@Data +@EqualsAndHashCode(callSuper = true) +public abstract class TenantBaseDO extends BaseDO { + + /** + * 多租户编号 + */ + private Long tenantId; + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantDatabaseInterceptor.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantDatabaseInterceptor.java new file mode 100644 index 0000000..d24f92c --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/TenantDatabaseInterceptor.java @@ -0,0 +1,43 @@ +package com.iailab.framework.tenant.core.db; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.tenant.config.TenantProperties; +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; + +import java.util.HashSet; +import java.util.Set; + +/** + * 基于 MyBatis Plus 多租户的功能,实现 DB 层面的多租户的功能 + * + * @author iailab + */ +public class TenantDatabaseInterceptor implements TenantLineHandler { + + private final Set<String> ignoreTables = new HashSet<>(); + + public TenantDatabaseInterceptor(TenantProperties properties) { + // 不同 DB 下,大小写的习惯不同,所以需要都添加进去 + properties.getIgnoreTables().forEach(table -> { + ignoreTables.add(table.toLowerCase()); + ignoreTables.add(table.toUpperCase()); + }); + // 在 OracleKeyGenerator 中,生成主键时,会查询这个表,查询这个表后,会自动拼接 TENANT_ID 导致报错 + ignoreTables.add("DUAL"); + } + + @Override + public Expression getTenantId() { + return new LongValue(TenantContextHolder.getRequiredTenantId()); + } + + @Override + public boolean ignoreTable(String tableName) { + return TenantContextHolder.isIgnore() // 情况一,全局忽略多租户 + || CollUtil.contains(ignoreTables, tableName); // 情况二,忽略多租户的表 + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/DataDS.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/DataDS.java new file mode 100644 index 0000000..fce0fbb --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/DataDS.java @@ -0,0 +1,25 @@ +package com.iailab.framework.tenant.core.db.dynamic; + +import com.baomidou.dynamic.datasource.annotation.DS; + +import java.lang.annotation.*; + +/** + * 使用数据源所在的数据源 + * + * 使用方式:当我们希望一个表使用租户所在的数据源,可以在该表的 Mapper 上添加该注解 + * + * @author 芋道源码 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@DS(DataDS.KEY) +public @interface DataDS { + + /** + * 数据源的占位符 + */ + String KEY = "#context.dataSourceId"; + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDS.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDS.java new file mode 100644 index 0000000..bf7da0c --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDS.java @@ -0,0 +1,25 @@ +package com.iailab.framework.tenant.core.db.dynamic; + +import com.baomidou.dynamic.datasource.annotation.DS; + +import java.lang.annotation.*; + +/** + * 使用租户所在的数据源 + * + * 使用方式:当我们希望一个表使用租户所在的数据源,可以在该表的 Mapper 上添加该注解 + * + * @author 芋道源码 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@DS(TenantDS.KEY) +public @interface TenantDS { + + /** + * 租户对应的数据源的占位符 + */ + String KEY = "#context.tenantId"; + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDsProcessor.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDsProcessor.java new file mode 100644 index 0000000..24b2eff --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/db/dynamic/TenantDsProcessor.java @@ -0,0 +1,106 @@ +package com.iailab.framework.tenant.core.db.dynamic; + +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.dynamic.datasource.creator.DataSourceProperty; +import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator; +import com.baomidou.dynamic.datasource.processor.DsProcessor; +import com.iailab.framework.tenant.core.context.DataContextHolder; +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.iailab.framework.tenant.core.service.TenantFrameworkService; +import com.iailab.module.infra.api.db.DataSourceConfigServiceApi; +import com.iailab.module.infra.api.db.dto.DataSourceConfigRespDTO; +import lombok.RequiredArgsConstructor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.context.annotation.Lazy; + +import javax.annotation.Resource; +import javax.sql.DataSource; +import java.util.Objects; + +/** + * 基于 {@link TenantDS} 的数据源处理器 + * + * 1. 如果有 @TenantDS 注解,返回该租户的数据源 + * 2. 如果该租户的数据源未创建,则进行创建 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +public class TenantDsProcessor extends DsProcessor { + + /** + * 用于获取租户数据源配置的 Service + */ + @Resource + @Lazy + private TenantFrameworkService tenantFrameworkService; + + /** + * 动态数据源 + */ + @Resource + @Lazy // 为什么添加 @Lazy 注解?因为它和 DynamicRoutingDataSource 相互依赖,导致无法初始化 + private DynamicRoutingDataSource dynamicRoutingDataSource; + + /** + * 用于创建租户数据源的 Creator + */ + @Resource + @Lazy + private DefaultDataSourceCreator dataSourceCreator; + + @Resource + @Lazy + private DataSourceConfigServiceApi dataSourceConfigServiceApi; + + @Override + public boolean matches(String key) { + return Objects.equals(key, TenantDS.KEY) || Objects.equals(key, DataDS.KEY); + } + + @Override + public String doDetermineDatasource(MethodInvocation invocation, String key) { + if (DataDS.KEY.equals(key)){ + // 获得数据源配置 + Long dataSourceId = DataContextHolder.getRequiredDataSourceId(); + DataSourceConfigRespDTO dataSourceConfigRespDTO = dataSourceConfigServiceApi.getDataSourceConfig(dataSourceId); + DataSourceProperty dataSourceProperty = new DataSourceProperty(); + dataSourceProperty.setPoolName(dataSourceConfigRespDTO.getName()); + dataSourceProperty.setUrl(dataSourceConfigRespDTO.getUrl()); + dataSourceProperty.setUsername(dataSourceConfigRespDTO.getUsername()); + dataSourceProperty.setPassword(dataSourceConfigRespDTO.getPassword()); + // 创建 or 创建数据源,并返回数据源名字 + return createDatasourceIfAbsent(dataSourceProperty); + }else if(TenantDS.KEY.equals(key)){ + // 获得数据源配置 + Long tenantId = TenantContextHolder.getRequiredTenantId(); + DataSourceProperty dataSourceProperty = tenantFrameworkService.getDataSourceProperty(tenantId); + // 创建 or 创建数据源,并返回数据源名字 + return createDatasourceIfAbsent(dataSourceProperty); + } + return key; + } + + private String createDatasourceIfAbsent(DataSourceProperty dataSourceProperty) { + // 1. 重点:如果数据源不存在,则进行创建 + if (isDataSourceNotExist(dataSourceProperty)) { + // 问题一:为什么要加锁?因为,如果多个线程同时执行到这里,会导致多次创建数据源 + // 问题二:为什么要使用 poolName 加锁?保证多个不同的 poolName 可以并发创建数据源 + // 问题三:为什么要使用 intern 方法?因为,intern 方法,会返回一个字符串的常量池中的引用 + // intern 的说明,可见 https://www.cnblogs.com/xrq730/p/6662232.html 文章 + synchronized (dataSourceProperty.getPoolName().intern()) { + if (isDataSourceNotExist(dataSourceProperty)) { + DataSource dataSource = dataSourceCreator.createDataSource(dataSourceProperty); + dynamicRoutingDataSource.addDataSource(dataSourceProperty.getPoolName(), dataSource); + } + } + } + // 2. 返回数据源的名字 + return dataSourceProperty.getPoolName(); + } + + private boolean isDataSourceNotExist(DataSourceProperty dataSourceProperty) { + return !dynamicRoutingDataSource.getDataSources().containsKey(dataSourceProperty.getPoolName()); + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJob.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJob.java new file mode 100644 index 0000000..fd790d7 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJob.java @@ -0,0 +1,14 @@ +package com.iailab.framework.tenant.core.job; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 多租户 Job 注解 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface TenantJob { +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJobAspect.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJobAspect.java new file mode 100644 index 0000000..5d7a9ac --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/job/TenantJobAspect.java @@ -0,0 +1,66 @@ +package com.iailab.framework.tenant.core.job; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.tenant.core.service.TenantFrameworkService; +import com.iailab.framework.tenant.core.util.TenantUtils; +import com.xxl.job.core.context.XxlJobHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 多租户 JobHandler AOP + * 任务执行时,会按照租户逐个执行 Job 的逻辑 + * + * 注意,需要保证 JobHandler 的幂等性。因为 Job 因为某个租户执行失败重试时,之前执行成功的租户也会再次执行。 + * + * @author iailab + */ +@Aspect +@RequiredArgsConstructor +@Slf4j +public class TenantJobAspect { + + private final TenantFrameworkService tenantFrameworkService; + + @Around("@annotation(tenantJob)") + public void around(ProceedingJoinPoint joinPoint, TenantJob tenantJob) { + // 获得租户列表 + List<Long> tenantIds = tenantFrameworkService.getTenantIds(); + if (CollUtil.isEmpty(tenantIds)) { + return; + } + + // 逐个租户,执行 Job + Map<Long, String> results = new ConcurrentHashMap<>(); + tenantIds.parallelStream().forEach(tenantId -> { + // TODO iailab:先通过 parallel 实现并行;1)多个租户,是一条执行日志;2)异常的情况 + TenantUtils.execute(tenantId, () -> { + try { + System.out.println("租户id:" + tenantId); + joinPoint.proceed(); + } catch (Throwable e) { + results.put(tenantId, ExceptionUtil.getRootCauseMessage(e)); + // 打印异常 + XxlJobHelper.log(StrUtil.format("[多租户({}) 执行任务({}),发生异常:{}]", + tenantId, joinPoint.getSignature(), ExceptionUtils.getStackTrace(e))); + } + }); + }); + // 如果 results 非空,说明发生了异常,标记 XXL-Job 执行失败 + if (CollUtil.isNotEmpty(results)) { + XxlJobHelper.handleFail(JsonUtils.toJsonString(results)); + } + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaEnvironmentPostProcessor.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaEnvironmentPostProcessor.java new file mode 100644 index 0000000..480b4c6 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaEnvironmentPostProcessor.java @@ -0,0 +1,37 @@ +package com.iailab.framework.tenant.core.mq.kafka; + +import cn.hutool.core.util.StrUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * 多租户的 Kafka 的 {@link EnvironmentPostProcessor} 实现类 + * + * Kafka Producer 发送消息时,增加 {@link TenantKafkaProducerInterceptor} 拦截器 + * + * @author iailab + */ +@Slf4j +public class TenantKafkaEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String PROPERTY_KEY_INTERCEPTOR_CLASSES = "spring.kafka.producer.properties.interceptor.classes"; + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 添加 TenantKafkaProducerInterceptor 拦截器 + try { + String value = environment.getProperty(PROPERTY_KEY_INTERCEPTOR_CLASSES); + if (StrUtil.isEmpty(value)) { + value = TenantKafkaProducerInterceptor.class.getName(); + } else { + value += "," + TenantKafkaProducerInterceptor.class.getName(); + } + environment.getSystemProperties().put(PROPERTY_KEY_INTERCEPTOR_CLASSES, value); + } catch (NoClassDefFoundError ignore) { + // 如果触发 NoClassDefFoundError 异常,说明 TenantKafkaProducerInterceptor 类不存在,即没引入 kafka-spring 依赖 + } + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java new file mode 100644 index 0000000..6bdcec3 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/kafka/TenantKafkaProducerInterceptor.java @@ -0,0 +1,47 @@ +package com.iailab.framework.tenant.core.mq.kafka; + +import cn.hutool.core.util.ReflectUtil; +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import org.apache.kafka.clients.producer.ProducerInterceptor; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.header.Headers; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import java.util.Map; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Kafka 消息队列的多租户 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author iailab + */ +public class TenantKafkaProducerInterceptor implements ProducerInterceptor<Object, Object> { + + @Override + public ProducerRecord<Object, Object> onSend(ProducerRecord<Object, Object> record) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + Headers headers = (Headers) ReflectUtil.getFieldValue(record, "headers"); // private 属性,没有 get 方法,智能反射 + headers.add(HEADER_TENANT_ID, tenantId.toString().getBytes()); + } + return record; + } + + @Override + public void onAcknowledgement(RecordMetadata metadata, Exception exception) { + } + + @Override + public void close() { + } + + @Override + public void configure(Map<String, ?> configs) { + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java new file mode 100644 index 0000000..b990d14 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQInitializer.java @@ -0,0 +1,23 @@ +package com.iailab.framework.tenant.core.mq.rabbitmq; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +/** + * 多租户的 RabbitMQ 初始化器 + * + * @author iailab + */ +public class TenantRabbitMQInitializer implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof RabbitTemplate) { + RabbitTemplate rabbitTemplate = (RabbitTemplate) bean; + rabbitTemplate.addBeforePublishPostProcessors(new TenantRabbitMQMessagePostProcessor()); + } + return bean; + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java new file mode 100644 index 0000000..577ae3d --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rabbitmq/TenantRabbitMQMessagePostProcessor.java @@ -0,0 +1,31 @@ +package com.iailab.framework.tenant.core.mq.rabbitmq; + +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import org.apache.kafka.clients.producer.ProducerInterceptor; +import org.springframework.amqp.AmqpException; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RabbitMQ 消息队列的多租户 {@link ProducerInterceptor} 实现类 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author iailab + */ +public class TenantRabbitMQMessagePostProcessor implements MessagePostProcessor { + + @Override + public Message postProcessMessage(Message message) throws AmqpException { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + message.getMessageProperties().getHeaders().put(HEADER_TENANT_ID, tenantId); + } + return message; + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java new file mode 100644 index 0000000..1c7ad18 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/redis/TenantRedisMessageInterceptor.java @@ -0,0 +1,42 @@ +package com.iailab.framework.tenant.core.mq.redis; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.iailab.framework.mq.redis.core.message.AbstractRedisMessage; +import com.iailab.framework.tenant.core.context.TenantContextHolder; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * 多租户 {@link AbstractRedisMessage} 拦截器 + * + * 1. Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * 2. Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中 + * + * @author iailab + */ +public class TenantRedisMessageInterceptor implements RedisMessageInterceptor { + + @Override + public void sendMessageBefore(AbstractRedisMessage message) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + message.addHeader(HEADER_TENANT_ID, tenantId.toString()); + } + } + + @Override + public void consumeMessageBefore(AbstractRedisMessage message) { + String tenantIdStr = message.getHeader(HEADER_TENANT_ID); + if (StrUtil.isNotEmpty(tenantIdStr)) { + TenantContextHolder.setTenantId(Long.valueOf(tenantIdStr)); + } + } + + @Override + public void consumeMessageAfter(AbstractRedisMessage message) { + // 注意,Consumer 是一个逻辑的入口,所以不考虑原本上下文就存在租户编号的情况 + TenantContextHolder.clear(); + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java new file mode 100644 index 0000000..c934b21 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQConsumeMessageHook.java @@ -0,0 +1,46 @@ +package com.iailab.framework.tenant.core.mq.rocketmq; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import org.apache.rocketmq.client.hook.ConsumeMessageContext; +import org.apache.rocketmq.client.hook.ConsumeMessageHook; +import org.apache.rocketmq.common.message.MessageExt; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; + +import java.util.List; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RocketMQ 消息队列的多租户 {@link ConsumeMessageHook} 实现类 + * + * Consumer 消费消息时,将消息的 Header 的租户编号,添加到 {@link TenantContextHolder} 中,通过 {@link InvocableHandlerMethod} 实现 + * + * @author iailab + */ +public class TenantRocketMQConsumeMessageHook implements ConsumeMessageHook { + + @Override + public String hookName() { + return getClass().getSimpleName(); + } + + @Override + public void consumeMessageBefore(ConsumeMessageContext context) { + // 校验,消息必须是单条,不然设置租户可能不正确 + List<MessageExt> messages = context.getMsgList(); + Assert.isTrue(messages.size() == 1, "消息条数({})不正确", messages.size()); + // 设置租户编号 + String tenantId = messages.get(0).getUserProperty(HEADER_TENANT_ID); + if (StrUtil.isNotEmpty(tenantId)) { + TenantContextHolder.setTenantId(Long.parseLong(tenantId)); + } + } + + @Override + public void consumeMessageAfter(ConsumeMessageContext context) { + TenantContextHolder.clear(); + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java new file mode 100644 index 0000000..eab04d7 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQInitializer.java @@ -0,0 +1,53 @@ +package com.iailab.framework.tenant.core.mq.rocketmq; + +import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer; +import org.apache.rocketmq.client.impl.consumer.DefaultMQPushConsumerImpl; +import org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl; +import org.apache.rocketmq.client.producer.DefaultMQProducer; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.apache.rocketmq.spring.support.DefaultRocketMQListenerContainer; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; + +/** + * 多租户的 RocketMQ 初始化器 + * + * @author iailab + */ +public class TenantRocketMQInitializer implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof DefaultRocketMQListenerContainer) { + DefaultRocketMQListenerContainer container = (DefaultRocketMQListenerContainer) bean; + initTenantConsumer(container.getConsumer()); + } else if (bean instanceof RocketMQTemplate) { + RocketMQTemplate template = (RocketMQTemplate) bean; + initTenantProducer(template.getProducer()); + } + return bean; + } + + private void initTenantProducer(DefaultMQProducer producer) { + if (producer == null) { + return; + } + DefaultMQProducerImpl producerImpl = producer.getDefaultMQProducerImpl(); + if (producerImpl == null) { + return; + } + producerImpl.registerSendMessageHook(new TenantRocketMQSendMessageHook()); + } + + private void initTenantConsumer(DefaultMQPushConsumer consumer) { + if (consumer == null) { + return; + } + DefaultMQPushConsumerImpl consumerImpl = consumer.getDefaultMQPushConsumerImpl(); + if (consumerImpl == null) { + return; + } + consumerImpl.registerConsumeMessageHook(new TenantRocketMQConsumeMessageHook()); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java new file mode 100644 index 0000000..6aac75d --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/mq/rocketmq/TenantRocketMQSendMessageHook.java @@ -0,0 +1,36 @@ +package com.iailab.framework.tenant.core.mq.rocketmq; + +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import org.apache.rocketmq.client.hook.SendMessageContext; +import org.apache.rocketmq.client.hook.SendMessageHook; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * RocketMQ 消息队列的多租户 {@link SendMessageHook} 实现类 + * + * Producer 发送消息时,将 {@link TenantContextHolder} 租户编号,添加到消息的 Header 中 + * + * @author iailab + */ +public class TenantRocketMQSendMessageHook implements SendMessageHook { + + @Override + public String hookName() { + return getClass().getSimpleName(); + } + + @Override + public void sendMessageBefore(SendMessageContext sendMessageContext) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + return; + } + sendMessageContext.getMessage().putUserProperty(HEADER_TENANT_ID, tenantId.toString()); + } + + @Override + public void sendMessageAfter(SendMessageContext sendMessageContext) { + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/redis/TenantRedisCacheManager.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/redis/TenantRedisCacheManager.java new file mode 100644 index 0000000..9394cf2 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/redis/TenantRedisCacheManager.java @@ -0,0 +1,46 @@ +package com.iailab.framework.tenant.core.redis; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.redis.core.TimeoutRedisCacheManager; +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; + +import java.util.Set; + +/** + * 多租户的 {@link RedisCacheManager} 实现类 + * + * 操作指定 name 的 {@link Cache} 时,自动拼接租户后缀,格式为 name + ":" + tenantId + 后缀 + * + * @author airhead + */ +@Slf4j +public class TenantRedisCacheManager extends TimeoutRedisCacheManager { + + private final Set<String> ignoreCaches; + + public TenantRedisCacheManager(RedisCacheWriter cacheWriter, + RedisCacheConfiguration defaultCacheConfiguration, + Set<String> ignoreCaches) { + super(cacheWriter, defaultCacheConfiguration); + this.ignoreCaches = ignoreCaches; + } + + @Override + public Cache getCache(String name) { + // 如果开启多租户,则 name 拼接租户后缀 + if (!TenantContextHolder.isIgnore() + && TenantContextHolder.getTenantId() != null + && !CollUtil.contains(ignoreCaches, name)) { + name = name + ":" + TenantContextHolder.getTenantId(); + } + + // 继续基于父方法 + return super.getCache(name); + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/rpc/TenantRequestInterceptor.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/rpc/TenantRequestInterceptor.java new file mode 100644 index 0000000..afb26f8 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/rpc/TenantRequestInterceptor.java @@ -0,0 +1,25 @@ +package com.iailab.framework.tenant.core.rpc; + +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import feign.RequestInterceptor; +import feign.RequestTemplate; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Tenant 的 RequestInterceptor 实现类:Feign 请求时,将 {@link TenantContextHolder} 设置到 header 中,继续透传给被调用的服务 + * + * @author iailab + */ +public class TenantRequestInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate requestTemplate) { + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + requestTemplate.header(HEADER_TENANT_ID, String.valueOf(tenantId)); + } + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/security/TenantSecurityWebFilter.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/security/TenantSecurityWebFilter.java new file mode 100644 index 0000000..bab0e73 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/security/TenantSecurityWebFilter.java @@ -0,0 +1,117 @@ +package com.iailab.framework.tenant.core.security; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import com.iailab.framework.tenant.config.TenantProperties; +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.iailab.framework.tenant.core.service.TenantFrameworkService; +import com.iailab.framework.web.config.WebProperties; +import com.iailab.framework.web.core.filter.ApiRequestFilter; +import com.iailab.framework.web.core.handler.GlobalExceptionHandler; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.AntPathMatcher; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Objects; + +/** + * 多租户 Security Web 过滤器 + * 1. 如果是登陆的用户,校验是否有权限访问该租户,避免越权问题。 + * 2. 如果请求未带租户的编号,检查是否是忽略的 URL,否则也不允许访问。 + * 3. 校验租户是合法,例如说被禁用、到期 + * + * @author iailab + */ +@Slf4j +public class TenantSecurityWebFilter extends ApiRequestFilter { + + private final TenantProperties tenantProperties; + + private final AntPathMatcher pathMatcher; + + private final GlobalExceptionHandler globalExceptionHandler; + private final TenantFrameworkService tenantFrameworkService; + + public TenantSecurityWebFilter(TenantProperties tenantProperties, + WebProperties webProperties, + GlobalExceptionHandler globalExceptionHandler, + TenantFrameworkService tenantFrameworkService) { + super(webProperties); + this.tenantProperties = tenantProperties; + this.pathMatcher = new AntPathMatcher(); + this.globalExceptionHandler = globalExceptionHandler; + this.tenantFrameworkService = tenantFrameworkService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + Long tenantId = TenantContextHolder.getTenantId(); + // 1. 登陆的用户,校验是否有权限访问该租户,避免越权问题。 + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user != null) { + // 如果获取不到租户编号,则尝试使用登陆用户的租户编号 + if (tenantId == null) { + tenantId = user.getTenantId(); + TenantContextHolder.setTenantId(tenantId); + // 如果传递了租户编号,则进行比对租户编号,避免越权问题 + } else if (!Objects.equals(user.getTenantId(), TenantContextHolder.getTenantId())) { + log.error("[doFilterInternal][租户({}) User({}/{}) 越权访问租户({}) URL({}/{})]", + user.getTenantId(), user.getId(), user.getUserType(), + TenantContextHolder.getTenantId(), request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.FORBIDDEN.getCode(), + "您无权访问该租户的数据")); + return; + } + } + + // 如果非允许忽略租户的 URL,则校验租户是否合法 + if (!isIgnoreUrl(request)) { + // 2. 如果请求未带租户的编号,不允许访问。 + if (tenantId == null) { + log.error("[doFilterInternal][URL({}/{}) 未传递租户编号]", request.getRequestURI(), request.getMethod()); + ServletUtils.writeJSON(response, CommonResult.error(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), + "请求的租户标识未传递,请进行排查")); + return; + } + // 3. 校验租户是合法,例如说被禁用、到期 + try { + tenantFrameworkService.validTenant(tenantId); + } catch (Throwable ex) { + CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } else { // 如果是允许忽略租户的 URL,若未传递租户编号,则默认忽略租户编号,避免报错 + if (tenantId == null) { + TenantContextHolder.setIgnore(true); + } + } + + // 继续过滤 + chain.doFilter(request, response); + } + + private boolean isIgnoreUrl(HttpServletRequest request) { + // 快速匹配,保证性能 + if (CollUtil.contains(tenantProperties.getIgnoreUrls(), request.getRequestURI())) { + return true; + } + // 逐个 Ant 路径匹配 + for (String url : tenantProperties.getIgnoreUrls()) { + if (pathMatcher.match(url, request.getRequestURI())) { + return true; + } + } + return false; + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkService.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkService.java new file mode 100644 index 0000000..92ed16a --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkService.java @@ -0,0 +1,36 @@ +package com.iailab.framework.tenant.core.service; + +import com.baomidou.dynamic.datasource.creator.DataSourceProperty; + +import java.util.List; + +/** + * Tenant 框架 Service 接口,定义获取租户信息 + * + * @author iailab + */ +public interface TenantFrameworkService { + + /** + * 获得所有租户 + * + * @return 租户编号数组 + */ + List<Long> getTenantIds(); + + /** + * 校验租户是否合法 + * + * @param id 租户编号 + */ + void validTenant(Long id); + + /** + * 获得租户对应的数据源配置 + * + * @param id 租户编号 + * @return 数据源配置 + */ + DataSourceProperty getDataSourceProperty(Long id); + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkServiceImpl.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkServiceImpl.java new file mode 100644 index 0000000..eef1cf4 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/service/TenantFrameworkServiceImpl.java @@ -0,0 +1,103 @@ +package com.iailab.framework.tenant.core.service; + +import com.baomidou.dynamic.datasource.creator.DataSourceProperty; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.cache.CacheUtils; +import com.iailab.module.system.api.tenant.TenantApi; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import com.iailab.module.system.api.tenant.dto.TenantDataSourceConfigRespDTO; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; + +import java.time.Duration; +import java.util.List; + +import static com.iailab.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache; + +/** + * Tenant 框架 Service 实现类 + * + * @author iailab + */ +@RequiredArgsConstructor +public class TenantFrameworkServiceImpl implements TenantFrameworkService { + + private final TenantApi tenantApi; + + /** + * 针对 {@link #getTenantIds()} 的缓存 + */ + private final LoadingCache<Object, List<Long>> getTenantIdsCache = buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<Object, List<Long>>() { + + @Override + public List<Long> load(Object key) { + return tenantApi.getTenantIdList().getCheckedData(); + } + + }); + + /** + * 针对 {@link #validTenant(Long)} 的缓存 + */ + private final LoadingCache<Long, CommonResult<Boolean>> validTenantCache = buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<Long, CommonResult<Boolean>>() { + + @Override + public CommonResult<Boolean> load(Long id) { + return tenantApi.validTenant(id); + } + + }); + + /** + * 针对 {@link #getDataSourceProperty(Long)} 的缓存 + */ + private final LoadingCache<Long, DataSourceProperty> dataSourcePropertyCache = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<Long, DataSourceProperty>() { + + @Override + public DataSourceProperty load(Long id) { + // 获得租户对应的数据源配置 + TenantDataSourceConfigRespDTO dataSourceConfig = tenantApi.getTenantDataSourceConfig(id); + if (dataSourceConfig == null) { + return null; + } + // 转换成 dynamic-datasource 配置 +// return new DataSourceProperty() +// .setPoolName(dataSourceConfig.getName()).setUrl(dataSourceConfig.getUrl()) +// .setUsername(dataSourceConfig.getUsername()).setPassword(dataSourceConfig.getPassword()); + + DataSourceProperty ds = new DataSourceProperty(); + ds.setPoolName(dataSourceConfig.getName()); + ds.setUrl(dataSourceConfig.getUrl()); + ds.setUsername(dataSourceConfig.getUsername()); + ds.setPassword(dataSourceConfig.getPassword()); + return ds; + } + + }); + + @Override + @SneakyThrows + public List<Long> getTenantIds() { + return getTenantIdsCache.get(Boolean.TRUE); + } + + @Override + @SneakyThrows + public void validTenant(Long id) { + validTenantCache.get(id).checkError(); + } + + @Override + @SneakyThrows + public DataSourceProperty getDataSourceProperty(Long id) { + return dataSourcePropertyCache.get(id); + } +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/util/TenantUtils.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/util/TenantUtils.java new file mode 100644 index 0000000..6f830cd --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/util/TenantUtils.java @@ -0,0 +1,93 @@ +package com.iailab.framework.tenant.core.util; + +import com.iailab.framework.tenant.core.context.TenantContextHolder; + +import java.util.Map; +import java.util.concurrent.Callable; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * 多租户 Util + * + * @author iailab + */ +public class TenantUtils { + + /** + * 使用指定租户,执行对应的逻辑 + * + * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户 + * 当然,执行完成后,还是会恢复回去 + * + * @param tenantId 租户编号 + * @param runnable 逻辑 + */ + public static void execute(Long tenantId, Runnable runnable) { + Long oldTenantId = TenantContextHolder.getTenantId(); + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setTenantId(tenantId); + TenantContextHolder.setIgnore(false); + // 执行逻辑 + runnable.run(); + } finally { + TenantContextHolder.setTenantId(oldTenantId); + TenantContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 使用指定租户,执行对应的逻辑 + * + * 注意,如果当前是忽略租户的情况下,会被强制设置成不忽略租户 + * 当然,执行完成后,还是会恢复回去 + * + * @param tenantId 租户编号 + * @param callable 逻辑 + */ + public static <V> V execute(Long tenantId, Callable<V> callable) { + Long oldTenantId = TenantContextHolder.getTenantId(); + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setTenantId(tenantId); + TenantContextHolder.setIgnore(false); + // 执行逻辑 + return callable.call(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + TenantContextHolder.setTenantId(oldTenantId); + TenantContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 忽略租户,执行对应的逻辑 + * + * @param runnable 逻辑 + */ + public static void executeIgnore(Runnable runnable) { + Boolean oldIgnore = TenantContextHolder.isIgnore(); + try { + TenantContextHolder.setIgnore(true); + // 执行逻辑 + runnable.run(); + } finally { + TenantContextHolder.setIgnore(oldIgnore); + } + } + + /** + * 将多租户编号,添加到 header 中 + * + * @param headers HTTP 请求 headers + * @param tenantId 租户编号 + */ + public static void addTenantHeader(Map<String, String> headers, Long tenantId) { + if (tenantId != null) { + headers.put(HEADER_TENANT_ID, tenantId.toString()); + } + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/web/TenantContextWebFilter.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/web/TenantContextWebFilter.java new file mode 100644 index 0000000..4973691 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/core/web/TenantContextWebFilter.java @@ -0,0 +1,37 @@ +package com.iailab.framework.tenant.core.web; + +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 多租户 Context Web 过滤器 + * 将请求 Header 中的 tenant-id 解析出来,添加到 {@link TenantContextHolder} 中,这样后续的 DB 等操作,可以获得到租户编号。 + * + * @author iailab + */ +public class TenantContextWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 设置 + Long tenantId = WebFrameworkUtils.getTenantId(request); + if (tenantId != null) { + TenantContextHolder.setTenantId(tenantId); + } + try { + chain.doFilter(request, response); + } finally { + // 清理 + TenantContextHolder.clear(); + } + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/package-info.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/package-info.java new file mode 100644 index 0000000..eaefcd7 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/com/iailab/framework/tenant/package-info.java @@ -0,0 +1,17 @@ +/** + * 多租户,支持如下层面: + * 1. DB:基于 MyBatis Plus 多租户的功能实现。 + * 2. Redis:通过在 Redis Key 上拼接租户编号的方式,进行隔离。 + * 3. Web:请求 HTTP API 时,解析 Header 的 tenant-id 租户编号,添加到租户上下文。 + * 4. Security:校验当前登陆的用户,是否越权访问其它租户的数据。 + * 5. Job:在 JobHandler 执行任务时,会按照每个租户,都独立并行执行一次。 + * 6. MQ:在 Producer 发送消息时,Header 带上 tenant-id 租户编号;在 Consumer 消费消息时,将 Header 的 tenant-id 租户编号,添加到租户上下文。 + * 7. Async:异步需要保证 ThreadLocal 的传递性,通过使用阿里开源的 TransmittableThreadLocal 实现。相关的改造点,可见: + * 1)Spring Async: + * {@link com.iailab.framework.quartz.config.IailabAsyncAutoConfiguration#threadPoolTaskExecutorBeanPostProcessor()} + * 2)Spring Security: + * TransmittableThreadLocalSecurityContextHolderStrategy + * 和 IailabSecurityAutoConfiguration#securityContextHolderMethodInvokingFactoryBean() 方法 + * + */ +package com.iailab.framework.tenant; diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java b/iailab-framework/iailab-common-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java new file mode 100644 index 0000000..6f282a2 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/java/org/springframework/messaging/handler/invocation/InvocableHandlerMethod.java @@ -0,0 +1,269 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.messaging.handler.invocation; + +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.iailab.framework.tenant.core.util.TenantUtils; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.HandlerMethod; +import org.springframework.util.ObjectUtils; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Arrays; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Extension of {@link HandlerMethod} that invokes the underlying method with + * argument values resolved from the current HTTP request through a list of + * {@link HandlerMethodArgumentResolver}. + * + * 针对 rabbitmq-spring 和 kafka-spring,不存在合适的拓展点,可以实现 Consumer 消费前,读取 Header 中的 tenant-id 设置到 {@link TenantContextHolder} 中 + * TODO iailab:持续跟进,看看有没新的拓展点 + * + * @author Rossen Stoyanchev + * @author Juergen Hoeller + * @since 4.0 + */ +public class InvocableHandlerMethod extends HandlerMethod { + + private static final Object[] EMPTY_ARGS = new Object[0]; + + private HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite(); + + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + /** + * Create an instance from a {@code HandlerMethod}. + */ + public InvocableHandlerMethod(HandlerMethod handlerMethod) { + super(handlerMethod); + } + + /** + * Create an instance from a bean instance and a method. + */ + public InvocableHandlerMethod(Object bean, Method method) { + super(bean, method); + } + + /** + * Construct a new handler method with the given bean instance, method name and parameters. + * @param bean the object bean + * @param methodName the method name + * @param parameterTypes the method parameter types + * @throws NoSuchMethodException when the method cannot be found + */ + public InvocableHandlerMethod(Object bean, String methodName, Class<?>... parameterTypes) + throws NoSuchMethodException { + + super(bean, methodName, parameterTypes); + } + + /** + * Set {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} to use for resolving method argument values. + */ + public void setMessageMethodArgumentResolvers(HandlerMethodArgumentResolverComposite argumentResolvers) { + this.resolvers = argumentResolvers; + } + + /** + * Set the ParameterNameDiscoverer for resolving parameter names when needed + * (e.g. default request attribute name). + * <p>Default is a {@link DefaultParameterNameDiscoverer}. + */ + public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) { + this.parameterNameDiscoverer = parameterNameDiscoverer; + } + + /** + * Invoke the method after resolving its argument values in the context of the given message. + * <p>Argument values are commonly resolved through + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * The {@code providedArgs} parameter however may supply argument values to be used directly, + * i.e. without argument resolution. + * <p>Delegates to {@link #getMethodArgumentValues} and calls {@link #doInvoke} with the + * resolved arguments. + * @param message the current message being processed + * @param providedArgs "given" arguments matched by type, not resolved + * @return the raw value returned by the invoked method + * @throws Exception raised if no suitable argument resolver can be found, + * or if the method raised an exception + * @see #getMethodArgumentValues + * @see #doInvoke + */ + @Nullable + public Object invoke(Message<?> message, Object... providedArgs) throws Exception { + Object[] args = getMethodArgumentValues(message, providedArgs); + if (logger.isTraceEnabled()) { + logger.trace("Arguments: " + Arrays.toString(args)); + } + // 注意:如下是本类的改动点!!! + // 情况一:无租户编号的情况 + Long tenantId= parseTenantId(message); + if (tenantId == null) { + return doInvoke(args); + } + // 情况二:有租户的情况下 + return TenantUtils.execute(tenantId, () -> doInvoke(args)); + } + + private Long parseTenantId(Message<?> message) { + Object tenantId = message.getHeaders().get(HEADER_TENANT_ID); + if (tenantId == null) { + return null; + } + if (tenantId instanceof Long) { + return (Long) tenantId; + } + if (tenantId instanceof Number) { + return ((Number) tenantId).longValue(); + } + if (tenantId instanceof String) { + return Long.parseLong((String) tenantId); + } + if (tenantId instanceof byte[]) { + return Long.parseLong(new String((byte[]) tenantId)); + } + throw new IllegalArgumentException("未知的数据类型:" + tenantId); + } + + /** + * Get the method argument values for the current message, checking the provided + * argument values and falling back to the configured argument resolvers. + * <p>The resulting array will be passed into {@link #doInvoke}. + * @since 5.1.2 + */ + protected Object[] getMethodArgumentValues(Message<?> message, Object... providedArgs) throws Exception { + MethodParameter[] parameters = getMethodParameters(); + if (ObjectUtils.isEmpty(parameters)) { + return EMPTY_ARGS; + } + + Object[] args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + MethodParameter parameter = parameters[i]; + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + args[i] = findProvidedArgument(parameter, providedArgs); + if (args[i] != null) { + continue; + } + if (!this.resolvers.supportsParameter(parameter)) { + throw new MethodArgumentResolutionException( + message, parameter, formatArgumentError(parameter, "No suitable resolver")); + } + try { + args[i] = this.resolvers.resolveArgument(parameter, message); + } + catch (Exception ex) { + // Leave stack trace for later, exception may actually be resolved and handled... + if (logger.isDebugEnabled()) { + String exMsg = ex.getMessage(); + if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) { + logger.debug(formatArgumentError(parameter, exMsg)); + } + } + throw ex; + } + } + return args; + } + + /** + * Invoke the handler method with the given argument values. + */ + @Nullable + protected Object doInvoke(Object... args) throws Exception { + try { + return getBridgedMethod().invoke(getBean(), args); + } + catch (IllegalArgumentException ex) { + assertTargetBean(getBridgedMethod(), getBean(), args); + String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument"); + throw new IllegalStateException(formatInvokeError(text, args), ex); + } + catch (InvocationTargetException ex) { + // Unwrap for HandlerExceptionResolvers ... + Throwable targetException = ex.getTargetException(); + if (targetException instanceof RuntimeException) { + throw (RuntimeException) targetException; + } + else if (targetException instanceof Error) { + throw (Error) targetException; + } + else if (targetException instanceof Exception) { + throw (Exception) targetException; + } + else { + throw new IllegalStateException(formatInvokeError("Invocation failure", args), targetException); + } + } + } + + MethodParameter getAsyncReturnValueType(@Nullable Object returnValue) { + return new AsyncResultMethodParameter(returnValue); + } + + private class AsyncResultMethodParameter extends HandlerMethodParameter { + + @Nullable + private final Object returnValue; + + private final ResolvableType returnType; + + public AsyncResultMethodParameter(@Nullable Object returnValue) { + super(-1); + this.returnValue = returnValue; + this.returnType = ResolvableType.forType(super.getGenericParameterType()).getGeneric(); + } + + protected AsyncResultMethodParameter(AsyncResultMethodParameter original) { + super(original); + this.returnValue = original.returnValue; + this.returnType = original.returnType; + } + + @Override + public Class<?> getParameterType() { + if (this.returnValue != null) { + return this.returnValue.getClass(); + } + if (!ResolvableType.NONE.equals(this.returnType)) { + return this.returnType.toClass(); + } + return super.getParameterType(); + } + + @Override + public Type getGenericParameterType() { + return this.returnType.getType(); + } + + @Override + public AsyncResultMethodParameter clone() { + return new AsyncResultMethodParameter(this); + } + } + +} diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring.factories b/iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..c03718f --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + com.iailab.framework.tenant.core.mq.kafka.TenantKafkaEnvironmentPostProcessor diff --git a/iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..b954183 --- /dev/null +++ b/iailab-framework/iailab-common-biz-tenant/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.iailab.framework.tenant.config.IailabTenantRpcAutoConfiguration +com.iailab.framework.tenant.config.IailabTenantAutoConfiguration diff --git a/iailab-framework/iailab-common-env/pom.xml b/iailab-framework/iailab-common-env/pom.xml new file mode 100644 index 0000000..2170152 --- /dev/null +++ b/iailab-framework/iailab-common-env/pom.xml @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-env</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description> + 开发环境拓展,实现类似阿里的特性环境的能力 + 1. https://segmentfault.com/a/1190000018022987 + </description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <properties> + <maven.compiler.source>8</maven.compiler.source> + <maven.compiler.target>8</maven.compiler.target> + </properties> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Spring 核心 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter</artifactId> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-web</artifactId> + </dependency> + + <dependency> + <groupId>jakarta.servlet</groupId> + <artifactId>jakarta.servlet-api</artifactId> + </dependency> + + <!-- RPC 相关 --> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-loadbalancer</artifactId> + </dependency> + <dependency> + <groupId>io.github.openfeign</groupId> + <artifactId>feign-core</artifactId> + </dependency> + + <!-- Registry 注册中心相关 --> + <dependency> + <groupId>com.alibaba.cloud</groupId> + <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvEnvironmentPostProcessor.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvEnvironmentPostProcessor.java new file mode 100644 index 0000000..ace59cc --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvEnvironmentPostProcessor.java @@ -0,0 +1,50 @@ +package com.iailab.framework.env.config; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.util.collection.SetUtils; +import com.iailab.framework.env.core.util.EnvUtils; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +import java.util.Set; + +import static com.iailab.framework.env.core.util.EnvUtils.HOST_NAME_VALUE; + +/** + * 多环境的 {@link EnvEnvironmentPostProcessor} 实现类 + * 将 iailab.env.tag 设置到 nacos 等组件对应的 tag 配置项,当且仅当它们不存在时 + * + * @author iailab + */ +public class EnvEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final Set<String> TARGET_TAG_KEYS = SetUtils.asSet( + "spring.cloud.nacos.discovery.metadata.tag" // Nacos 注册中心 + // MQ TODO + ); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 0. 设置 ${HOST_NAME} 兜底的环境变量 + String hostNameKey = StrUtil.subBetween(HOST_NAME_VALUE, "{", "}"); + if (!environment.containsProperty(hostNameKey)) { + environment.getSystemProperties().put(hostNameKey, EnvUtils.getHostName()); + } + + // 1.1 如果没有 iailab.env.tag 配置项,则不进行配置项的修改 + String tag = EnvUtils.getTag(environment); + if (StrUtil.isEmpty(tag)) { + return; + } + // 1.2 需要修改的配置项 + for (String targetTagKey : TARGET_TAG_KEYS) { + String targetTagValue = environment.getProperty(targetTagKey); + if (StrUtil.isNotEmpty(targetTagValue)) { + continue; + } + environment.getSystemProperties().put(targetTagKey, tag); + } + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvProperties.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvProperties.java new file mode 100644 index 0000000..b17699f --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/EnvProperties.java @@ -0,0 +1,22 @@ +package com.iailab.framework.env.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 环境配置 + * + * @author iailab + */ +@ConfigurationProperties(prefix = "iailab.env") +@Data +public class EnvProperties { + + public static final String TAG_KEY = "iailab.env.tag"; + + /** + * 环境标签 + */ + private String tag; + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvRpcAutoConfiguration.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvRpcAutoConfiguration.java new file mode 100644 index 0000000..ca16975 --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvRpcAutoConfiguration.java @@ -0,0 +1,46 @@ +package com.iailab.framework.env.config; + +import com.iailab.framework.env.core.fegin.EnvLoadBalancerClientFactory; +import com.iailab.framework.env.core.fegin.EnvRequestInterceptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties; +import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClientSpecification; +import org.springframework.cloud.loadbalancer.config.LoadBalancerAutoConfiguration; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; +import org.springframework.context.annotation.Bean; + +import java.util.Collections; +import java.util.List; + +/** + * 多环境的 RPC 组件的自动配置 + * + * @author iailab + */ +@AutoConfiguration +@EnableConfigurationProperties(EnvProperties.class) +public class IailabEnvRpcAutoConfiguration { + + // ========== Feign 相关 ========== + + /** + * 创建 {@link EnvLoadBalancerClientFactory} Bean + * + * 参考 {@link LoadBalancerAutoConfiguration#loadBalancerClientFactory(LoadBalancerClientsProperties)} 方法 + */ + @Bean + public LoadBalancerClientFactory loadBalancerClientFactory(LoadBalancerClientsProperties properties, + ObjectProvider<List<LoadBalancerClientSpecification>> configurations) { + EnvLoadBalancerClientFactory clientFactory = new EnvLoadBalancerClientFactory(properties); + clientFactory.setConfigurations(configurations.getIfAvailable(Collections::emptyList)); + return clientFactory; + } + + @Bean + public EnvRequestInterceptor envRequestInterceptor() { + return new EnvRequestInterceptor(); + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvWebAutoConfiguration.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvWebAutoConfiguration.java new file mode 100644 index 0000000..10b14f3 --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/config/IailabEnvWebAutoConfiguration.java @@ -0,0 +1,32 @@ +package com.iailab.framework.env.config; + +import com.iailab.framework.common.enums.WebFilterOrderEnum; +import com.iailab.framework.env.core.web.EnvWebFilter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; + +/** + * 多环境的 Web 组件的自动配置 + * + * @author iailab + */ +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@EnableConfigurationProperties(EnvProperties.class) +public class IailabEnvWebAutoConfiguration { + + /** + * 创建 {@link EnvWebFilter} Bean + */ + @Bean + public FilterRegistrationBean<EnvWebFilter> envWebFilterFilter() { + EnvWebFilter filter = new EnvWebFilter(); + FilterRegistrationBean<EnvWebFilter> bean = new FilterRegistrationBean<>(filter); + bean.setOrder(WebFilterOrderEnum.ENV_TAG_FILTER); + return bean; + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/context/EnvContextHolder.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/context/EnvContextHolder.java new file mode 100644 index 0000000..551db9b --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/context/EnvContextHolder.java @@ -0,0 +1,39 @@ +package com.iailab.framework.env.core.context; + +import cn.hutool.core.collection.CollUtil; +import com.alibaba.ttl.TransmittableThreadLocal; + +import java.util.ArrayList; +import java.util.List; + +/** + * 开发环境上下文 + * + * @author iailab + */ +public class EnvContextHolder { + + /** + * 标签的上下文 + * + * 使用 {@link List} 的原因,可能存在多层设置或者清理 + */ + private static final ThreadLocal<List<String>> TAG_CONTEXT = TransmittableThreadLocal.withInitial(ArrayList::new); + + public static void setTag(String tag) { + TAG_CONTEXT.get().add(tag); + } + + public static String getTag() { + return CollUtil.getLast(TAG_CONTEXT.get()); + } + + public static void removeTag() { + List<String> tags = TAG_CONTEXT.get(); + if (CollUtil.isEmpty(tags)) { + return; + } + tags.remove(tags.size() - 1); + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClient.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClient.java new file mode 100644 index 0000000..fb43806 --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClient.java @@ -0,0 +1,83 @@ +package com.iailab.framework.env.core.fegin; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.util.collection.CollectionUtils; +import com.iailab.framework.env.core.context.EnvContextHolder; +import com.iailab.framework.env.core.util.EnvUtils; +import com.alibaba.cloud.nacos.balancer.NacosBalancer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.DefaultResponse; +import org.springframework.cloud.client.loadbalancer.EmptyResponse; +import org.springframework.cloud.client.loadbalancer.Request; +import org.springframework.cloud.client.loadbalancer.Response; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.loadbalancer.core.NoopServiceInstanceListSupplier; +import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; +import reactor.core.publisher.Mono; + +import java.util.List; + +/** + * 多环境的 {@link org.springframework.cloud.client.loadbalancer.LoadBalancerClient} 实现类 + * 在从服务实例列表选择时,优先选择 tag 匹配的服务实例 + * + * @author iailab + */ +@RequiredArgsConstructor +@Slf4j +public class EnvLoadBalancerClient implements ReactorServiceInstanceLoadBalancer { + + /** + * 用于获取 serviceId 对应的服务实例的列表 + */ + private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider; + /** + * 需要获取的服务实例名 + * + * 暂时用于打印 logger 日志 + */ + private final String serviceId; + /** + * 被代理的 ReactiveLoadBalancer 对象 + */ + private final ReactiveLoadBalancer<ServiceInstance> reactiveLoadBalancer; + + @Override + public Mono<Response<ServiceInstance>> choose(Request request) { + // 情况一,没有 tag 时,使用默认的 reactiveLoadBalancer 实现负载均衡 + String tag = EnvContextHolder.getTag(); + if (StrUtil.isEmpty(tag)) { + return Mono.from(reactiveLoadBalancer.choose(request)); + } + + // 情况二,有 tag 时,使用 tag 匹配服务实例 + ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new); + return supplier.get(request).next().map(list -> getInstanceResponse(list, tag)); + } + + private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances, String tag) { + // 如果服务实例为空,则直接返回 + if (CollUtil.isEmpty(instances)) { + log.warn("[getInstanceResponse][serviceId({}) 服务实例列表为空]", serviceId); + return new EmptyResponse(); + } + + // 筛选满足条件的实例列表 + List<ServiceInstance> chooseInstances = CollectionUtils.filterList(instances, instance -> tag.equals(EnvUtils.getTag(instance))); + if (CollUtil.isEmpty(chooseInstances)) { + log.warn("[getInstanceResponse][serviceId({}) 没有满足 tag({}) 的服务实例列表,直接使用所有服务实例列表]", serviceId, tag); + chooseInstances = instances; + } + + // TODO iailab:https://juejin.cn/post/7056770721858469896 想通网段 + + // 随机 + 权重获取实例列表 TODO iailab:目前直接使用 Nacos 提供的方法,如果替换注册中心,需要重新失败该方法 + return new DefaultResponse(NacosBalancer.getHostByRandomWeight3(chooseInstances)); + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClientFactory.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClientFactory.java new file mode 100644 index 0000000..ba728f5 --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvLoadBalancerClientFactory.java @@ -0,0 +1,30 @@ +package com.iailab.framework.env.core.fegin; + + +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties; +import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer; +import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; +import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory; + +/** + * 多环境的 {@link LoadBalancerClientFactory} 实现类 + * 目的:在创建 {@link ReactiveLoadBalancer} 时,会额外增加 {@link EnvLoadBalancerClient} 代理,用于 tag 过滤服务实例 + * + * @author iailab + */ +public class EnvLoadBalancerClientFactory extends LoadBalancerClientFactory { + + public EnvLoadBalancerClientFactory(LoadBalancerClientsProperties properties) { + super(properties); + } + + @Override + public ReactiveLoadBalancer<ServiceInstance> getInstance(String serviceId) { + ReactiveLoadBalancer<ServiceInstance> reactiveLoadBalancer = super.getInstance(serviceId); + // 参考 {@link com.alibaba.cloud.nacos.loadbalancer.NacosLoadBalancerClientConfiguration#nacosLoadBalancer(Environment, LoadBalancerClientFactory, NacosDiscoveryProperties)} 方法 + return new EnvLoadBalancerClient(super.getLazyProvider(serviceId, ServiceInstanceListSupplier.class), + serviceId, reactiveLoadBalancer); + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvRequestInterceptor.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvRequestInterceptor.java new file mode 100644 index 0000000..6242683 --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/fegin/EnvRequestInterceptor.java @@ -0,0 +1,24 @@ +package com.iailab.framework.env.core.fegin; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.env.core.context.EnvContextHolder; +import com.iailab.framework.env.core.util.EnvUtils; +import feign.RequestInterceptor; +import feign.RequestTemplate; + +/** + * 多环境的 {@link RequestInterceptor} 实现类:Feign 请求时,将 tag 设置到 header 中,继续透传给被调用的服务 + * + * @author iailab + */ +public class EnvRequestInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate requestTemplate) { + String tag = EnvContextHolder.getTag(); + if (StrUtil.isNotEmpty(tag)) { + EnvUtils.setTag(requestTemplate, tag); + } + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/package-info.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/package-info.java new file mode 100644 index 0000000..304a953 --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/package-info.java @@ -0,0 +1 @@ +package com.iailab.framework.env.core; diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/util/EnvUtils.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/util/EnvUtils.java new file mode 100644 index 0000000..c010e2e --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/util/EnvUtils.java @@ -0,0 +1,56 @@ +package com.iailab.framework.env.core.util; + +import com.iailab.framework.env.config.EnvProperties; +import feign.RequestTemplate; +import lombok.SneakyThrows; +import org.springframework.cloud.client.ServiceInstance; +import org.springframework.core.env.Environment; + +import javax.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.util.Objects; + +/** + * 环境 Utils + * + * @author iailab + */ +public class EnvUtils { + + private static final String HEADER_TAG = "tag"; + + public static final String HOST_NAME_VALUE = "${HOSTNAME}"; + + public static String getTag(HttpServletRequest request) { + String tag = request.getHeader(HEADER_TAG); + // 如果请求的是 "${HOSTNAME}",则解析成对应的本地主机名 + // 目的:特殊逻辑,解决 IDEA Rest Client 不支持环境变量的读取,所以就服务器来做 + return Objects.equals(tag, HOST_NAME_VALUE) ? getHostName() : tag; + } + + public static String getTag(ServiceInstance instance) { + return instance.getMetadata().get(HEADER_TAG); + } + + public static String getTag(Environment environment) { + String tag = environment.getProperty(EnvProperties.TAG_KEY); + // 如果请求的是 "${HOSTNAME}",则解析成对应的本地主机名 + // 目的:特殊逻辑,解决 IDEA Rest Client 不支持环境变量的读取,所以就服务器来做 + return Objects.equals(tag, HOST_NAME_VALUE) ? getHostName() : tag; + } + + public static void setTag(RequestTemplate requestTemplate, String tag) { + requestTemplate.header(HEADER_TAG, tag); + } + + /** + * 获得 hostname 主机名 + * + * @return 主机名 + */ + @SneakyThrows + public static String getHostName() { + return InetAddress.getLocalHost().getHostName(); + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/web/EnvWebFilter.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/web/EnvWebFilter.java new file mode 100644 index 0000000..9d1912f --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/core/web/EnvWebFilter.java @@ -0,0 +1,41 @@ +package com.iailab.framework.env.core.web; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.env.core.context.EnvContextHolder; +import com.iailab.framework.env.core.util.EnvUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * 环境的 {@link javax.servlet.Filter} 实现类 + * 当有 tag 请求头时,设置到 {@link EnvContextHolder} 的标签上下文 + * + * @author iailab + */ +public class EnvWebFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 如果没有 tag,则走默认的流程 + String tag = EnvUtils.getTag(request); + if (StrUtil.isEmpty(tag)) { + chain.doFilter(request, response); + return; + } + + // 如果有 tag,则设置到上下文 + EnvContextHolder.setTag(tag); + try { + chain.doFilter(request, response); + } finally { + EnvContextHolder.removeTag(); + } + } + +} diff --git a/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/package-info.java b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/package-info.java new file mode 100644 index 0000000..cf90d7c --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/java/com/iailab/framework/env/package-info.java @@ -0,0 +1,7 @@ +/** + * 开发环境拓展,实现类似阿里的特性环境的能力 + * 1. https://segmentfault.com/a/1190000018022987 + * + * @author iailab + */ +package com.iailab.framework.env; diff --git a/iailab-framework/iailab-common-env/src/main/resources/META-INF/spring.factories b/iailab-framework/iailab-common-env/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..e7a9ffe --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + com.iailab.framework.env.config.EnvEnvironmentPostProcessor diff --git a/iailab-framework/iailab-common-env/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-env/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f727275 --- /dev/null +++ b/iailab-framework/iailab-common-env/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.iailab.framework.env.config.IailabEnvWebAutoConfiguration +com.iailab.framework.env.config.IailabEnvRpcAutoConfiguration diff --git a/iailab-framework/iailab-common-excel/pom.xml b/iailab-framework/iailab-common-excel/pom.xml new file mode 100644 index 0000000..a069490 --- /dev/null +++ b/iailab-framework/iailab-common-excel/pom.xml @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-excel</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>Excel 拓展</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Spring 核心 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter</artifactId> + </dependency> + + <!-- RPC 远程调用相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-rpc</artifactId> + <optional>true</optional> + </dependency> + + <!-- 业务组件 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-module-system-api</artifactId> <!-- 需要使用它,进行 Dict 的查询 --> + <version>${revision}</version> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-web</artifactId> + <scope>provided</scope> <!-- 设置为 provided,只有 ExcelUtils 使用 --> + </dependency> + + <dependency> + <groupId>jakarta.servlet</groupId> + <artifactId>jakarta.servlet-api</artifactId> + <scope>provided</scope> <!-- 设置为 provided,只有 ExcelUtils 使用 --> + </dependency> + + <!-- 工具类相关 --> + <dependency> + <groupId>com.alibaba</groupId> + <artifactId>easyexcel</artifactId> + </dependency> + + <dependency> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> + </dependency> + + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-biz-ip</artifactId> + <optional>true</optional> <!-- 设置为 optional,只有在 AreaConvert 的时候使用 --> + </dependency> + + <!-- Test 测试相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-test</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictAutoConfiguration.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictAutoConfiguration.java new file mode 100644 index 0000000..9f1ed42 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.iailab.framework.dict.config; + +import com.iailab.framework.dict.core.DictFrameworkUtils; +import com.iailab.module.system.api.dict.DictDataApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class IailabDictAutoConfiguration { + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public DictFrameworkUtils dictUtils(DictDataApi dictDataApi) { + DictFrameworkUtils.init(dictDataApi); + return new DictFrameworkUtils(); + } + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictRpcAutoConfiguration.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictRpcAutoConfiguration.java new file mode 100644 index 0000000..aff4b46 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/config/IailabDictRpcAutoConfiguration.java @@ -0,0 +1,15 @@ +package com.iailab.framework.dict.config; + +import com.iailab.module.system.api.dict.DictDataApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * 字典用到 Feign 的配置项 + * + * @author iailab + */ +@AutoConfiguration +@EnableFeignClients(clients = DictDataApi.class) // 主要是引入相关的 API 服务 +public class IailabDictRpcAutoConfiguration { +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/core/DictFrameworkUtils.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/core/DictFrameworkUtils.java new file mode 100644 index 0000000..307d8b6 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/core/DictFrameworkUtils.java @@ -0,0 +1,96 @@ +package com.iailab.framework.dict.core; + +import cn.hutool.core.util.ObjectUtil; +import com.iailab.framework.common.core.KeyValue; +import com.iailab.framework.common.util.cache.CacheUtils; +import com.iailab.module.system.api.dict.DictDataApi; +import com.iailab.module.system.api.dict.dto.DictDataRespDTO; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.util.List; + +/** + * 字典工具类 + * + * @author iailab + */ +@Slf4j +public class DictFrameworkUtils { + + private static DictDataApi dictDataApi; + + private static final DictDataRespDTO DICT_DATA_NULL = new DictDataRespDTO(); + + // TODO @puhui999:GET_DICT_DATA_CACHE、GET_DICT_DATA_LIST_CACHE、PARSE_DICT_DATA_CACHE 这 3 个缓存是有点重叠,可以思考下,有没可能减少 1 个。微信讨论好私聊,再具体改哈 + /** + * 针对 {@link #getDictDataLabel(String, String)} 的缓存 + */ + private static final LoadingCache<KeyValue<String, String>, DictDataRespDTO> GET_DICT_DATA_CACHE = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<KeyValue<String, String>, DictDataRespDTO>() { + + @Override + public DictDataRespDTO load(KeyValue<String, String> key) { + return ObjectUtil.defaultIfNull(dictDataApi.getDictData(key.getKey(), key.getValue()).getCheckedData(), DICT_DATA_NULL); + } + + }); + + /** + * 针对 {@link #getDictDataLabelList(String)} 的缓存 + */ + private static final LoadingCache<String, List<String>> GET_DICT_DATA_LIST_CACHE = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<String, List<String>>() { + + @Override + public List<String> load(String dictType) { + return dictDataApi.getDictDataLabelList(dictType); + } + + }); + + /** + * 针对 {@link #parseDictDataValue(String, String)} 的缓存 + */ + private static final LoadingCache<KeyValue<String, String>, DictDataRespDTO> PARSE_DICT_DATA_CACHE = CacheUtils.buildAsyncReloadingCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<KeyValue<String, String>, DictDataRespDTO>() { + + @Override + public DictDataRespDTO load(KeyValue<String, String> key) { + return ObjectUtil.defaultIfNull(dictDataApi.parseDictData(key.getKey(), key.getValue()).getCheckedData(), DICT_DATA_NULL); + } + + }); + + public static void init(DictDataApi dictDataApi) { + DictFrameworkUtils.dictDataApi = dictDataApi; + log.info("[init][初始化 DictFrameworkUtils 成功]"); + } + + @SneakyThrows + public static String getDictDataLabel(String dictType, Integer value) { + return GET_DICT_DATA_CACHE.get(new KeyValue<>(dictType, String.valueOf(value))).getLabel(); + } + + @SneakyThrows + public static String getDictDataLabel(String dictType, String value) { + return GET_DICT_DATA_CACHE.get(new KeyValue<>(dictType, value)).getLabel(); + } + + @SneakyThrows + public static List<String> getDictDataLabelList(String dictType) { + return GET_DICT_DATA_LIST_CACHE.get(dictType); + } + + @SneakyThrows + public static String parseDictDataValue(String dictType, String label) { + return PARSE_DICT_DATA_CACHE.get(new KeyValue<>(dictType, label)).getValue(); + } + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/package-info.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/package-info.java new file mode 100644 index 0000000..b7a0386 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/dict/package-info.java @@ -0,0 +1,6 @@ +/** + * 字典数据模块,提供 {@link com.iailab.framework.dict.core.DictFrameworkUtils} 工具类 + * + * 通过将字典缓存在内存中,保证性能 + */ +package com.iailab.framework.dict; diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/DictFormat.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/DictFormat.java new file mode 100644 index 0000000..9cd3996 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/DictFormat.java @@ -0,0 +1,22 @@ +package com.iailab.framework.excel.core.annotations; + +import java.lang.annotation.*; + +/** + * 字典格式化 + * + * 实现将字典数据的值,格式化成字典数据的标签 + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface DictFormat { + + /** + * 例如说,SysDictTypeConstants、InfDictTypeConstants + * + * @return 字典类型 + */ + String value(); + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/ExcelColumnSelect.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/ExcelColumnSelect.java new file mode 100644 index 0000000..7956094 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/annotations/ExcelColumnSelect.java @@ -0,0 +1,27 @@ +package com.iailab.framework.excel.core.annotations; + +import java.lang.annotation.*; + +/** + * 给 Excel 列添加下拉选择数据 + * + * 其中 {@link #dictType()} 和 {@link #functionName()} 二选一 + * + * @author HUIHUI + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface ExcelColumnSelect { + + /** + * @return 字典类型 + */ + String dictType() default ""; + + /** + * @return 获取下拉数据源的方法名称 + */ + String functionName() default ""; + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/AreaConvert.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/AreaConvert.java new file mode 100644 index 0000000..558c5f7 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/AreaConvert.java @@ -0,0 +1,46 @@ +package com.iailab.framework.excel.core.convert; + +import cn.hutool.core.convert.Convert; +import com.iailab.framework.ip.core.Area; +import com.iailab.framework.ip.core.utils.AreaUtils; +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.ReadCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; +import lombok.extern.slf4j.Slf4j; + +/** + * Excel 数据地区转换器 + * + * @author HUIHUI + */ +@Slf4j +public class AreaConvert implements Converter<Object> { + + @Override + public Class<?> supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public Object convertToJavaData(ReadCellData readCellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 解析地区编号 + String label = readCellData.getStringValue(); + Area area = AreaUtils.parseArea(label); + if (area == null) { + log.error("[convertToJavaData][label({}) 解析不掉]", label); + return null; + } + // 将 value 转换成对应的属性 + Class<?> fieldClazz = contentProperty.getField().getType(); + return Convert.convert(fieldClazz, area.getId()); + } + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/DictConvert.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/DictConvert.java new file mode 100644 index 0000000..9b667f3 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/DictConvert.java @@ -0,0 +1,72 @@ +package com.iailab.framework.excel.core.convert; + +import cn.hutool.core.convert.Convert; +import com.iailab.framework.dict.core.DictFrameworkUtils; +import com.iailab.framework.excel.core.annotations.DictFormat; +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.ReadCellData; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; +import lombok.extern.slf4j.Slf4j; + +/** + * Excel 数据字典转换器 + * + * @author iailab + */ +@Slf4j +public class DictConvert implements Converter<Object> { + + @Override + public Class<?> supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public Object convertToJavaData(ReadCellData readCellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 使用字典解析 + String type = getType(contentProperty); + String label = readCellData.getStringValue(); + String value = DictFrameworkUtils.parseDictDataValue(type, label); + if (value == null) { + log.error("[convertToJavaData][type({}) 解析不掉 label({})]", type, label); + return null; + } + // 将 String 的 value 转换成对应的属性 + Class<?> fieldClazz = contentProperty.getField().getType(); + return Convert.convert(fieldClazz, value); + } + + @Override + public WriteCellData<String> convertToExcelData(Object object, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 空时,返回空 + if (object == null) { + return new WriteCellData<>(""); + } + + // 使用字典格式化 + String type = getType(contentProperty); + String value = String.valueOf(object); + String label = DictFrameworkUtils.getDictDataLabel(type, value); + if (label == null) { + log.error("[convertToExcelData][type({}) 转换不了 label({})]", type, value); + return new WriteCellData<>(""); + } + // 生成 Excel 小表格 + return new WriteCellData<>(label); + } + + private static String getType(ExcelContentProperty contentProperty) { + return contentProperty.getField().getAnnotation(DictFormat.class).value(); + } + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/JsonConvert.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/JsonConvert.java new file mode 100644 index 0000000..5a29d08 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/JsonConvert.java @@ -0,0 +1,34 @@ +package com.iailab.framework.excel.core.convert; + +import com.iailab.framework.common.util.json.JsonUtils; +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; + +/** + * Excel Json 转换器 + * + * @author iailab + */ +public class JsonConvert implements Converter<Object> { + + @Override + public Class<?> supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public WriteCellData<String> convertToExcelData(Object value, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 生成 Excel 小表格 + return new WriteCellData<>(JsonUtils.toJsonString(value)); + } + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/MoneyConvert.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/MoneyConvert.java new file mode 100644 index 0000000..fce1e64 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/convert/MoneyConvert.java @@ -0,0 +1,39 @@ +package com.iailab.framework.excel.core.convert; + +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 金额转换器 + * + * 金额单位:分 + * + * @author iailab + */ +public class MoneyConvert implements Converter<Integer> { + + @Override + public Class<?> supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public WriteCellData<String> convertToExcelData(Integer value, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + BigDecimal result = BigDecimal.valueOf(value) + .divide(new BigDecimal(100), 2, RoundingMode.HALF_UP); + return new WriteCellData<>(result.toString()); + } + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/function/ExcelColumnSelectFunction.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/function/ExcelColumnSelectFunction.java new file mode 100644 index 0000000..a817d4b --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/function/ExcelColumnSelectFunction.java @@ -0,0 +1,28 @@ +package com.iailab.framework.excel.core.function; + +import java.util.List; + +/** + * Excel 列下拉数据源获取接口 + * + * 为什么不直接解析字典还搞个接口?考虑到有的下拉数据不是从字典中获取的所有需要做一个兼容 + + * @author HUIHUI + */ +public interface ExcelColumnSelectFunction { + + /** + * 获得方法名称 + * + * @return 方法名称 + */ + String getName(); + + /** + * 获得列下拉数据源 + * + * @return 下拉数据源 + */ + List<String> getOptions(); + +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/handler/SelectSheetWriteHandler.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/handler/SelectSheetWriteHandler.java new file mode 100644 index 0000000..9e5e3df --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/handler/SelectSheetWriteHandler.java @@ -0,0 +1,186 @@ +package com.iailab.framework.excel.core.handler; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.hutool.poi.excel.ExcelUtil; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.write.handler.SheetWriteHandler; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder; +import com.iailab.framework.common.core.KeyValue; +import com.iailab.framework.dict.core.DictFrameworkUtils; +import com.iailab.framework.excel.core.annotations.ExcelColumnSelect; +import com.iailab.framework.excel.core.function.ExcelColumnSelectFunction; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.hssf.usermodel.HSSFDataValidation; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.ss.util.CellRangeAddressList; + +import java.lang.reflect.Field; +import java.util.*; + +import static com.iailab.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 基于固定 sheet 实现下拉框 + * + * @author HUIHUI + */ +@Slf4j +public class SelectSheetWriteHandler implements SheetWriteHandler { + + /** + * 数据起始行从 0 开始 + * 约定:本项目第一行有标题所以从 1 开始如果您的 Excel 有多行标题请自行更改 + */ + public static final int FIRST_ROW = 1; + /** + * 下拉列需要创建下拉框的行数,默认两千行如需更多请自行调整 + */ + public static final int LAST_ROW = 2000; + + private static final String DICT_SHEET_NAME = "字典sheet"; + + /** + * key: 列 value: 下拉数据源 + */ + private final Map<Integer, List<String>> selectMap = new HashMap<>(); + + private static Boolean ifSetSelect; + + public SelectSheetWriteHandler(Class<?> head, Boolean selectFlag) { + ifSetSelect = selectFlag; + // 加载下拉数据获取接口 + Map<String, ExcelColumnSelectFunction> beansMap = SpringUtil.getBeanFactory().getBeansOfType(ExcelColumnSelectFunction.class); + if (MapUtil.isEmpty(beansMap)) { + return; + } + List<Field> fields = new ArrayList<>(); + for (Class<?> c = head; c != null; c = c.getSuperclass()) { + Collections.addAll(fields, c.getDeclaredFields()); + } + // 解析下拉数据 + int colIndex = 0; + for (Field field : fields) { + if (field.isAnnotationPresent(ExcelColumnSelect.class)) { + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelProperty != null && excelProperty.index() != -1) { + getSelectDataList(excelProperty.index(), field); + }else{ + getSelectDataList(colIndex, field); + } + } + colIndex++; + } + } + + /** + * 获得下拉数据,并添加到 {@link #selectMap} 中 + * + * @param colIndex 列索引 + * @param field 字段 + */ + private void getSelectDataList(int colIndex, Field field) { + ExcelColumnSelect columnSelect = field.getAnnotation(ExcelColumnSelect.class); + String dictType = columnSelect.dictType(); + String functionName = columnSelect.functionName(); + Assert.isTrue(ObjectUtil.isNotEmpty(dictType) || ObjectUtil.isNotEmpty(functionName), + "Field({}) 的 @ExcelColumnSelect 注解,dictType 和 functionName 不能同时为空", field.getName()); + + // 情况一:使用 dictType 获得下拉数据 + if (StrUtil.isNotEmpty(dictType)) { // 情况一: 字典数据 (默认) + selectMap.put(colIndex, DictFrameworkUtils.getDictDataLabelList(dictType)); + return; + } + + // 情况二:使用 functionName 获得下拉数据 + Map<String, ExcelColumnSelectFunction> functionMap = SpringUtil.getApplicationContext().getBeansOfType(ExcelColumnSelectFunction.class); + ExcelColumnSelectFunction function = CollUtil.findOne(functionMap.values(), item -> item.getName().equals(functionName)); + Assert.notNull(function, "未找到对应的 function({})", functionName); + selectMap.put(colIndex, function.getOptions()); + } + + @Override + public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { + if (CollUtil.isEmpty(selectMap)) { + return; + } + // 1. 获取相应操作对象 + DataValidationHelper helper = writeSheetHolder.getSheet().getDataValidationHelper(); // 需要设置下拉框的 sheet 页的数据验证助手 + Workbook workbook = writeWorkbookHolder.getWorkbook(); // 获得工作簿 + List<KeyValue<Integer, List<String>>> keyValues = convertList(selectMap.entrySet(), entry -> new KeyValue<>(entry.getKey(), entry.getValue())); + keyValues.sort(Comparator.comparing(item -> item.getValue().size())); // 升序不然创建下拉会报错 + if (ifSetSelect){ + for (KeyValue<Integer, List<String>> keyValue : keyValues) { + /*起始行、终止行、起始列、终止列 起始行为1即表示表头不设置**/ + CellRangeAddressList addressList = new CellRangeAddressList(FIRST_ROW, LAST_ROW, keyValue.getKey(), keyValue.getKey()); + /*设置下拉框数据**/ + DataValidationConstraint constraint = helper.createExplicitListConstraint(keyValue.getValue().toArray(new String[0])); + DataValidation dataValidation = helper.createValidation(constraint, addressList); + if (dataValidation instanceof HSSFDataValidation) { + dataValidation.setSuppressDropDownArrow(false); + } else { + dataValidation.setSuppressDropDownArrow(true); + dataValidation.setShowErrorBox(true); + } + // 2.2 阻止输入非下拉框的值 + dataValidation.setErrorStyle(DataValidation.ErrorStyle.STOP); + dataValidation.createErrorBox("提示", "此值不存在于下拉选择中!"); + writeSheetHolder.getSheet().addValidationData(dataValidation); + } + }else{ + // 2. 创建数据字典的 sheet 页 + Sheet dictSheet = workbook.createSheet(DICT_SHEET_NAME); + for (KeyValue<Integer, List<String>> keyValue : keyValues) { + int rowLength = keyValue.getValue().size(); + // 2.1 设置字典 sheet 页的值,每一列一部字典项 + for (int i = 0; i < rowLength; i++) { + Row row = dictSheet.getRow(i); + if (row == null) { + row = dictSheet.createRow(i); + } + row.createCell(keyValue.getKey()).setCellValue(keyValue.getValue().get(i)); + } + // 2.2 设置单元格下拉选择 + setColumnSelect(writeSheetHolder, workbook, helper, keyValue); + } + } + } + + /** + * 设置单元格下拉选择 + */ + private static void setColumnSelect(WriteSheetHolder writeSheetHolder, Workbook workbook, DataValidationHelper helper, + KeyValue<Integer, List<String>> keyValue) { + // 1.1 创建可被其他单元格引用的名称 + Name name = workbook.createName(); + String excelColumn = ExcelUtil.indexToColName(keyValue.getKey()); + // 1.2 下拉框数据来源 eg:字典sheet!$B1:$B2 + String refers = DICT_SHEET_NAME + "!$" + excelColumn + "$1:$" + excelColumn + "$" + keyValue.getValue().size(); + name.setNameName("dict" + keyValue.getKey()); // 设置名称的名字 + name.setRefersToFormula(refers); // 设置公式 + + // 2.1 设置约束 + DataValidationConstraint constraint = helper.createFormulaListConstraint("dict" + keyValue.getKey()); // 设置引用约束 + // 设置下拉单元格的首行、末行、首列、末列 + CellRangeAddressList rangeAddressList = new CellRangeAddressList(FIRST_ROW, LAST_ROW, + keyValue.getKey(), keyValue.getKey()); + DataValidation validation = helper.createValidation(constraint, rangeAddressList); + if (validation instanceof HSSFDataValidation) { + validation.setSuppressDropDownArrow(false); + } else { + validation.setSuppressDropDownArrow(true); + validation.setShowErrorBox(true); + } + // 2.2 阻止输入非下拉框的值 + validation.setErrorStyle(DataValidation.ErrorStyle.STOP); + validation.createErrorBox("提示", "此值不存在于下拉选择中!"); + // 2.3 添加下拉框约束 + writeSheetHolder.getSheet().addValidationData(validation); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/util/ExcelUtils.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/util/ExcelUtils.java new file mode 100644 index 0000000..f16434e --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/core/util/ExcelUtils.java @@ -0,0 +1,66 @@ +package com.iailab.framework.excel.core.util; + +import com.iailab.framework.excel.core.handler.SelectSheetWriteHandler; +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.converters.longconverter.LongStringConverter; +import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Excel 工具类 + * + * @author iailab + */ +public class ExcelUtils { + + /** + * 将列表以 Excel 响应给前端 + * + * @param response 响应 + * @param filename 文件名 + * @param sheetName Excel sheet 名 + * @param head Excel head 头 + * @param data 数据列表哦 + * @param <T> 泛型,保证 head 和 data 类型的一致性 + * @throws IOException 写入失败的情况 + */ + public static <T> void write(HttpServletResponse response, String filename, String sheetName, + Class<T> head, List<T> data) throws IOException { + // 输出 Excel + EasyExcel.write(response.getOutputStream(), head) + .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 + .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度 + .registerWriteHandler(new SelectSheetWriteHandler(head,false)) // 基于固定 sheet 实现下拉框 + .registerConverter(new LongStringConverter()) // 避免 Long 类型丢失精度 + .sheet(sheetName).doWrite(data); + // 设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了 + response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8.name())); + response.setContentType("application/vnd.ms-excel;charset=UTF-8"); + } + + public static <T> List<T> read(MultipartFile file, Class<T> head) throws IOException { + return EasyExcel.read(file.getInputStream(), head, null) + .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 + .doReadAllSync(); + } + + public static <T> void write(HttpServletResponse response, String filename, String sheetName, + Class<T> head, List<T> data, boolean selectFlag) throws IOException { + // 输出 Excel + EasyExcel.write(response.getOutputStream(), head) + .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 + .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 基于 column 长度,自动适配。最大 255 宽度 + .registerWriteHandler(new SelectSheetWriteHandler(head,selectFlag)) // 基于固定 sheet 实现下拉框 + .registerConverter(new LongStringConverter()) // 避免 Long 类型丢失精度 + .sheet(sheetName).doWrite(data); + // 设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了 + response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, StandardCharsets.UTF_8.name())); + response.setContentType("application/vnd.ms-excel;charset=UTF-8"); + } +} diff --git a/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/package-info.java b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/package-info.java new file mode 100644 index 0000000..21caa19 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/java/com/iailab/framework/excel/package-info.java @@ -0,0 +1,4 @@ +/** + * 基于 EasyExcel 实现 Excel 相关的操作 + */ +package com.iailab.framework.excel; diff --git a/iailab-framework/iailab-common-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..889b89f --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.iailab.framework.dict.config.IailabDictRpcAutoConfiguration +com.iailab.framework.dict.config.IailabDictAutoConfiguration diff --git a/iailab-framework/iailab-common-excel/src/test/java/com/iailab/framework/dict/core/util/DictFrameworkUtilsTest.java b/iailab-framework/iailab-common-excel/src/test/java/com/iailab/framework/dict/core/util/DictFrameworkUtilsTest.java new file mode 100644 index 0000000..157eae1 --- /dev/null +++ b/iailab-framework/iailab-common-excel/src/test/java/com/iailab/framework/dict/core/util/DictFrameworkUtilsTest.java @@ -0,0 +1,51 @@ +package com.iailab.framework.dict.core.util; + +import com.iailab.framework.common.enums.CommonStatusEnum; +import com.iailab.framework.dict.core.DictFrameworkUtils; +import com.iailab.framework.test.core.ut.BaseMockitoUnitTest; +import com.iailab.module.system.api.dict.DictDataApi; +import com.iailab.module.system.api.dict.dto.DictDataRespDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import static com.iailab.framework.common.pojo.CommonResult.success; +import static com.iailab.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +/** + * {@link DictFrameworkUtils} 的单元测试 + */ +public class DictFrameworkUtilsTest extends BaseMockitoUnitTest { + + @Mock + private DictDataApi dictDataApi; + + @BeforeEach + public void setUp() { + DictFrameworkUtils.init(dictDataApi); + } + + @Test + public void testGetDictDataLabel() { + // mock 数据 + DictDataRespDTO dataRespDTO = randomPojo(DictDataRespDTO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + // mock 方法 + when(dictDataApi.getDictData(dataRespDTO.getDictType(), dataRespDTO.getValue())).thenReturn(success(dataRespDTO)); + + // 断言返回值 + assertEquals(dataRespDTO.getLabel(), DictFrameworkUtils.getDictDataLabel(dataRespDTO.getDictType(), dataRespDTO.getValue())); + } + + @Test + public void testParseDictDataValue() { + // mock 数据 + DictDataRespDTO resp = randomPojo(DictDataRespDTO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + // mock 方法 + when(dictDataApi.parseDictData(resp.getDictType(), resp.getLabel())).thenReturn(success(resp)); + // 断言返回值 + assertEquals(resp.getValue(), DictFrameworkUtils.parseDictDataValue(resp.getDictType(), resp.getLabel())); + } + +} diff --git a/iailab-framework/iailab-common-job/pom.xml b/iailab-framework/iailab-common-job/pom.xml new file mode 100644 index 0000000..38dd07a --- /dev/null +++ b/iailab-framework/iailab-common-job/pom.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-job</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>任务拓展,基于 XXL-Job 实现</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Spring 核心 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter</artifactId> + <optional>true</optional> + </dependency> + + <!-- Job 相关 --> + <dependency> + <groupId>com.xuxueli</groupId> + <artifactId>xxl-job-core</artifactId> + </dependency> + + <!-- 工具类相关 --> + <dependency> + <groupId>jakarta.validation</groupId> + <artifactId>jakarta.validation-api</artifactId> + </dependency> + + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabAsyncAutoConfiguration.java b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabAsyncAutoConfiguration.java new file mode 100644 index 0000000..58c2947 --- /dev/null +++ b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabAsyncAutoConfiguration.java @@ -0,0 +1,37 @@ +package com.iailab.framework.quartz.config; + +import com.alibaba.ttl.TtlRunnable; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** + * 异步任务 Configuration + */ +@AutoConfiguration +@EnableAsync +public class IailabAsyncAutoConfiguration { + + @Bean + public BeanPostProcessor threadPoolTaskExecutorBeanPostProcessor() { + return new BeanPostProcessor() { + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + if (!(bean instanceof ThreadPoolTaskExecutor)) { + return bean; + } + // 修改提交的任务,接入 TransmittableThreadLocal + ThreadPoolTaskExecutor executor = (ThreadPoolTaskExecutor) bean; + executor.setTaskDecorator(TtlRunnable::get); + return executor; + } + + }; + } + +} diff --git a/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabXxlJobAutoConfiguration.java b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabXxlJobAutoConfiguration.java new file mode 100644 index 0000000..297ce1f --- /dev/null +++ b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/IailabXxlJobAutoConfiguration.java @@ -0,0 +1,47 @@ +package com.iailab.framework.quartz.config; + +import com.xxl.job.core.executor.XxlJobExecutor; +import com.xxl.job.core.executor.impl.XxlJobSpringExecutor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * XXL-Job 自动配置类 + * + * @author iailab + */ +@AutoConfiguration +@ConditionalOnClass(XxlJobSpringExecutor.class) +@ConditionalOnProperty(prefix = "xxl.job", name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties({XxlJobProperties.class}) +@EnableScheduling // 开启 Spring 自带的定时任务 +@Slf4j +public class IailabXxlJobAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public XxlJobExecutor xxlJobExecutor(XxlJobProperties properties) { + log.info("[xxlJobExecutor][初始化 XXL-Job 执行器的配置]"); + XxlJobProperties.AdminProperties admin = properties.getAdmin(); + XxlJobProperties.ExecutorProperties executor = properties.getExecutor(); + + // 初始化执行器 + XxlJobExecutor xxlJobExecutor = new XxlJobSpringExecutor(); + xxlJobExecutor.setIp(executor.getIp()); + xxlJobExecutor.setPort(executor.getPort()); + xxlJobExecutor.setAppname(executor.getAppName()); + xxlJobExecutor.setLogPath(executor.getLogPath()); + xxlJobExecutor.setLogRetentionDays(executor.getLogRetentionDays()); + xxlJobExecutor.setAdminAddresses(admin.getAddresses()); + xxlJobExecutor.setAccessToken(properties.getAccessToken()); + return xxlJobExecutor; + } + +} diff --git a/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/XxlJobProperties.java b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/XxlJobProperties.java new file mode 100644 index 0000000..5d09c7b --- /dev/null +++ b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/config/XxlJobProperties.java @@ -0,0 +1,99 @@ +package com.iailab.framework.quartz.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * XXL-Job 配置类 + */ +@ConfigurationProperties("xxl.job") +@Validated +@Data +public class XxlJobProperties { + + /** + * 是否开启,默认为 true 关闭 + */ + private Boolean enabled = true; + /** + * 访问令牌 + */ + private String accessToken; + /** + * 控制器配置 + */ + @NotNull(message = "控制器配置不能为空") + private AdminProperties admin; + /** + * 执行器配置 + */ + @NotNull(message = "执行器配置不能为空") + private ExecutorProperties executor; + + /** + * XXL-Job 调度器配置类 + */ + @Data + @Valid + public static class AdminProperties { + + /** + * 调度器地址 + */ + @NotEmpty(message = "调度器地址不能为空") + private String addresses; + + } + + /** + * XXL-Job 执行器配置类 + */ + @Data + @Valid + public static class ExecutorProperties { + + /** + * 默认端口 + * + * 这里使用 -1 表示随机 + */ + private static final Integer PORT_DEFAULT = -1; + + /** + * 默认日志保留天数 + * + * 如果想永久保留,则设置为 -1 + */ + private static final Integer LOG_RETENTION_DAYS_DEFAULT = 30; + + /** + * 应用名 + */ + @NotEmpty(message = "应用名不能为空") + private String appName; + /** + * 执行器的 IP + */ + private String ip; + /** + * 执行器的 Port + */ + private Integer port = PORT_DEFAULT; + /** + * 日志地址 + */ + @NotEmpty(message = "日志地址不能为空") + private String logPath; + /** + * 日志保留天数 + */ + private Integer logRetentionDays = LOG_RETENTION_DAYS_DEFAULT; + + } + +} diff --git a/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/package-info.java b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/package-info.java new file mode 100644 index 0000000..38ff695 --- /dev/null +++ b/iailab-framework/iailab-common-job/src/main/java/com/iailab/framework/quartz/package-info.java @@ -0,0 +1,5 @@ +/** + * 1. 定时任务,基于 XXL-Job 实现。 + * 2. 异步任务,采用 Spring Async 异步执行。 + */ +package com.iailab.framework.quartz; diff --git a/iailab-framework/iailab-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..f76be21 --- /dev/null +++ b/iailab-framework/iailab-common-job/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.iailab.framework.quartz.config.IailabXxlJobAutoConfiguration +com.iailab.framework.quartz.config.IailabAsyncAutoConfiguration diff --git a/iailab-framework/iailab-common-monitor/pom.xml b/iailab-framework/iailab-common-monitor/pom.xml new file mode 100644 index 0000000..2746113 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/pom.xml @@ -0,0 +1,73 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-monitor</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>服务监控,提供链路追踪、日志服务、指标收集等等功能</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Spring 核心 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-aop</artifactId> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-web</artifactId> + <scope>provided</scope> <!-- 设置为 provided,只有 TraceFilter 使用 --> + </dependency> + + <dependency> + <groupId>jakarta.servlet</groupId> + <artifactId>jakarta.servlet-api</artifactId> + <scope>provided</scope> <!-- 设置为 provided,只有 TraceFilter 使用 --> + </dependency> + + <!-- 监控相关 --> + <dependency> + <groupId>io.opentracing</groupId> + <artifactId>opentracing-util</artifactId> + </dependency> + <dependency> + <groupId>org.apache.skywalking</groupId> + <artifactId>apm-toolkit-trace</artifactId> + </dependency> + <dependency> + <groupId>org.apache.skywalking</groupId> + <artifactId>apm-toolkit-logback-1.x</artifactId> + </dependency> + <dependency> + <groupId>org.apache.skywalking</groupId> + <artifactId>apm-toolkit-opentracing</artifactId> + </dependency> + + <!-- Micrometer 对 Prometheus 的支持 --> + <dependency> + <groupId>io.micrometer</groupId> + <artifactId>micrometer-registry-prometheus</artifactId> + </dependency> + + <dependency> + <groupId>de.codecentric</groupId> + <artifactId>spring-boot-admin-starter-client</artifactId> <!-- 实现 Spring Boot Admin Server 服务端 --> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabMetricsAutoConfiguration.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabMetricsAutoConfiguration.java new file mode 100644 index 0000000..9d46110 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabMetricsAutoConfiguration.java @@ -0,0 +1,28 @@ +package com.iailab.framework.tracer.config; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Metrics 配置类 + * + * @author iailab + */ +@AutoConfiguration +@ConditionalOnClass({MeterRegistryCustomizer.class}) +@ConditionalOnProperty(prefix = "iailab.metrics", value = "enable", matchIfMissing = true) // 允许使用 iailab.metrics.enable=false 禁用 Metrics +public class IailabMetricsAutoConfiguration { + + @Bean + public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags( + @Value("${spring.application.name}") String applicationName) { + return registry -> registry.config().commonTags("application", applicationName); + } + +} diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabTracerAutoConfiguration.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabTracerAutoConfiguration.java new file mode 100644 index 0000000..48072c5 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/IailabTracerAutoConfiguration.java @@ -0,0 +1,55 @@ +package com.iailab.framework.tracer.config; + +import com.iailab.framework.common.enums.WebFilterOrderEnum; +import com.iailab.framework.tracer.core.aop.BizTraceAspect; +import com.iailab.framework.tracer.core.filter.TraceFilter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; + +/** + * Tracer 配置类 + * + * @author mashu + */ +@AutoConfiguration +@ConditionalOnClass({BizTraceAspect.class}) +@EnableConfigurationProperties(TracerProperties.class) +@ConditionalOnProperty(prefix = "iailab.tracer", value = "enable", matchIfMissing = true) +public class IailabTracerAutoConfiguration { + + // TODO @iailab:重要。目前 opentracing 版本存在冲突,要么保证 skywalking,要么保证阿里云短信 sdk +// @Bean +// public TracerProperties bizTracerProperties() { +// return new TracerProperties(); +// } +// +// @Bean +// public BizTraceAspect bizTracingAop() { +// return new BizTraceAspect(tracer()); +// } +// +// @Bean +// public Tracer tracer() { +// // 创建 SkywalkingTracer 对象 +// SkywalkingTracer tracer = new SkywalkingTracer(); +// // 设置为 GlobalTracer 的追踪器 +// GlobalTracer.register(tracer); +// return tracer; +// } + + /** + * 创建 TraceFilter 过滤器,响应 header 设置 traceId + */ + @Bean + public FilterRegistrationBean<TraceFilter> traceFilter() { + FilterRegistrationBean<TraceFilter> registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new TraceFilter()); + registrationBean.setOrder(WebFilterOrderEnum.TRACE_FILTER); + return registrationBean; + } + +} diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/TracerProperties.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/TracerProperties.java new file mode 100644 index 0000000..a779e16 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/config/TracerProperties.java @@ -0,0 +1,14 @@ +package com.iailab.framework.tracer.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * BizTracer配置类 + * + * @author 麻薯 + */ +@ConfigurationProperties("iailab.tracer") +@Data +public class TracerProperties { +} diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/annotation/BizTrace.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/annotation/BizTrace.java new file mode 100644 index 0000000..074bb18 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/annotation/BizTrace.java @@ -0,0 +1,42 @@ +package com.iailab.framework.tracer.core.annotation; + +import java.lang.annotation.*; + +/** + * 打印业务编号 / 业务类型注解 + * + * 使用时,需要设置 SkyWalking OAP Server 的 application.yaml 配置文件,修改 SW_SEARCHABLE_TAG_KEYS 配置项, + * 增加 biz.type 和 biz.id 两值,然后重启 SkyWalking OAP Server 服务器。 + * + * @author 麻薯 + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +public @interface BizTrace { + + /** + * 业务编号 tag 名 + */ + String ID_TAG = "biz.id"; + /** + * 业务类型 tag 名 + */ + String TYPE_TAG = "biz.type"; + + /** + * @return 操作名 + */ + String operationName() default ""; + + /** + * @return 业务编号 + */ + String id(); + + /** + * @return 业务类型 + */ + String type(); + +} diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/aop/BizTraceAspect.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/aop/BizTraceAspect.java new file mode 100644 index 0000000..954e933 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/aop/BizTraceAspect.java @@ -0,0 +1,77 @@ +package com.iailab.framework.tracer.core.aop; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.tracer.core.annotation.BizTrace; +import com.iailab.framework.common.util.spring.SpringExpressionUtils; +import com.iailab.framework.tracer.core.util.TracerFrameworkUtils; +import io.opentracing.Span; +import io.opentracing.Tracer; +import io.opentracing.tag.Tags; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +import java.util.Map; + +import static java.util.Arrays.asList; + +/** + * {@link BizTrace} 切面,记录业务链路 + * + * @author mashu + */ +@Aspect +@AllArgsConstructor +@Slf4j +public class BizTraceAspect { + + private static final String BIZ_OPERATION_NAME_PREFIX = "Biz/"; + + private final Tracer tracer; + + @Around(value = "@annotation(trace)") + public Object around(ProceedingJoinPoint joinPoint, BizTrace trace) throws Throwable { + // 创建 span + String operationName = getOperationName(joinPoint, trace); + Span span = tracer.buildSpan(operationName) + .withTag(Tags.COMPONENT.getKey(), "biz") + .start(); + try { + // 执行原有方法 + return joinPoint.proceed(); + } catch (Throwable throwable) { + TracerFrameworkUtils.onError(throwable, span); + throw throwable; + } finally { + // 设置 Span 的 biz 属性 + setBizTag(span, joinPoint, trace); + // 完成 Span + span.finish(); + } + } + + private String getOperationName(ProceedingJoinPoint joinPoint, BizTrace trace) { + // 自定义操作名 + if (StrUtil.isNotEmpty(trace.operationName())) { + return BIZ_OPERATION_NAME_PREFIX + trace.operationName(); + } + // 默认操作名,使用方法名 + return BIZ_OPERATION_NAME_PREFIX + + joinPoint.getSignature().getDeclaringType().getSimpleName() + + "/" + joinPoint.getSignature().getName(); + } + + private void setBizTag(Span span, ProceedingJoinPoint joinPoint, BizTrace trace) { + try { + Map<String, Object> result = SpringExpressionUtils.parseExpressions(joinPoint, asList(trace.type(), trace.id())); + span.setTag(BizTrace.TYPE_TAG, MapUtil.getStr(result, trace.type())); + span.setTag(BizTrace.ID_TAG, MapUtil.getStr(result, trace.id())); + } catch (Exception ex) { + log.error("[setBizTag][解析 bizType 与 bizId 发生异常]", ex); + } + } + +} diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/filter/TraceFilter.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/filter/TraceFilter.java new file mode 100644 index 0000000..75f943c --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/filter/TraceFilter.java @@ -0,0 +1,33 @@ +package com.iailab.framework.tracer.core.filter; + +import com.iailab.framework.common.util.monitor.TracerUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Trace 过滤器,打印 traceId 到 header 中返回 + * + * @author iailab + */ +public class TraceFilter extends OncePerRequestFilter { + + /** + * Header 名 - 链路追踪编号 + */ + private static final String HEADER_NAME_TRACE_ID = "trace-id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + // 设置响应 traceId + response.addHeader(HEADER_NAME_TRACE_ID, TracerUtils.getTraceId()); + // 继续过滤 + chain.doFilter(request, response); + } + +} diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/util/TracerFrameworkUtils.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/util/TracerFrameworkUtils.java new file mode 100644 index 0000000..0e08f91 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/core/util/TracerFrameworkUtils.java @@ -0,0 +1,46 @@ +package com.iailab.framework.tracer.core.util; + +import io.opentracing.Span; +import io.opentracing.tag.Tags; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +/** + * 链路追踪 Util + * + * @author iailab + */ +public class TracerFrameworkUtils { + + /** + * 将异常记录到 Span 中,参考自 com.aliyuncs.utils.TraceUtils + * + * @param throwable 异常 + * @param span Span + */ + public static void onError(Throwable throwable, Span span) { + Tags.ERROR.set(span, Boolean.TRUE); + if (throwable != null) { + span.log(errorLogs(throwable)); + } + } + + private static Map<String, Object> errorLogs(Throwable throwable) { + Map<String, Object> errorLogs = new HashMap<String, Object>(10); + errorLogs.put("event", Tags.ERROR.getKey()); + errorLogs.put("error.object", throwable); + errorLogs.put("error.kind", throwable.getClass().getName()); + String message = throwable.getCause() != null ? throwable.getCause().getMessage() : throwable.getMessage(); + if (message != null) { + errorLogs.put("message", message); + } + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + errorLogs.put("stack", sw.toString()); + return errorLogs; + } + +} diff --git a/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/package-info.java b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/package-info.java new file mode 100644 index 0000000..681acc6 --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/java/com/iailab/framework/tracer/package-info.java @@ -0,0 +1,6 @@ +/** + * 使用 SkyWalking 组件,作为链路追踪、日志中心。 + * + * @author iailab + */ +package com.iailab.framework.tracer; diff --git a/iailab-framework/iailab-common-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..d5e538e --- /dev/null +++ b/iailab-framework/iailab-common-monitor/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.iailab.framework.tracer.config.IailabTracerAutoConfiguration +com.iailab.framework.tracer.config.IailabMetricsAutoConfiguration diff --git a/iailab-framework/iailab-common-mq/pom.xml b/iailab-framework/iailab-common-mq/pom.xml new file mode 100644 index 0000000..29b9b2b --- /dev/null +++ b/iailab-framework/iailab-common-mq/pom.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-mq</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>消息队列,支持 Redis、RocketMQ、RabbitMQ、Kafka 四种</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <!-- DB 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-redis</artifactId> + </dependency> + + <!-- 消息队列相关 --> + <dependency> + <groupId>org.springframework.kafka</groupId> + <artifactId>spring-kafka</artifactId> +<!-- <optional>true</optional>--> + </dependency> + <dependency> + <groupId>org.springframework.amqp</groupId> + <artifactId>spring-rabbit</artifactId> +<!-- <optional>true</optional>--> + </dependency> + <dependency> + <groupId>org.apache.rocketmq</groupId> + <artifactId>rocketmq-spring-boot-starter</artifactId> +<!-- <optional>true</optional>--> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/common/RoutingConstant.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/common/RoutingConstant.java new file mode 100644 index 0000000..47d1c14 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/common/RoutingConstant.java @@ -0,0 +1,22 @@ +package com.iailab.framework.mq.common; + +/** + * + * + * @author PanZhibao + * @Description + * @createTime 2024年11月05日 + */ +public interface RoutingConstant { + + String Iailab_Data_PointCollectFinish = "Iailab.Data.PointCollectFinish"; + + // 摄像头通配路由 + String Iailab_Data_Image = "Iailab.Data.Image.*"; + + // 大华摄像头路由 + String Iailab_Data_Image_Dahua = "Iailab.Data.Image.Dahua"; + + // 海康摄像头路由 + String Iailab_Data_Image_Hikvision = "Iailab.Data.Image.Hikvision"; +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/package-info.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/package-info.java new file mode 100644 index 0000000..74fea2a --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/package-info.java @@ -0,0 +1,4 @@ +/** + * 消息队列,支持 Redis、RocketMQ、RabbitMQ、Kafka 四种 + */ +package com.iailab.framework.mq; diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/config/IailabRabbitMQAutoConfiguration.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/config/IailabRabbitMQAutoConfiguration.java new file mode 100644 index 0000000..2efeb3b --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/config/IailabRabbitMQAutoConfiguration.java @@ -0,0 +1,28 @@ +package com.iailab.framework.mq.rabbitmq.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; + +/** + * RabbitMQ 消息队列配置类 + * + * @author iailab + */ +@AutoConfiguration +@Slf4j +@ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate") +public class IailabRabbitMQAutoConfiguration { + + /** + * Jackson2JsonMessageConverter Bean:使用 jackson 序列化消息 + */ + @Bean + public MessageConverter createMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/core/package-info.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/core/package-info.java new file mode 100644 index 0000000..1340868 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位符,无特殊逻辑 + */ +package com.iailab.framework.mq.rabbitmq.core; \ No newline at end of file diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/package-info.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/package-info.java new file mode 100644 index 0000000..1582bcb --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/rabbitmq/package-info.java @@ -0,0 +1,4 @@ +/** + * 消息队列,基于 RabbitMQ 提供 + */ +package com.iailab.framework.mq.rabbitmq; diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQConsumerAutoConfiguration.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQConsumerAutoConfiguration.java new file mode 100644 index 0000000..187f863 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQConsumerAutoConfiguration.java @@ -0,0 +1,151 @@ +package com.iailab.framework.mq.redis.config; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.system.SystemUtil; +import com.iailab.framework.common.enums.DocumentEnum; +import com.iailab.framework.mq.redis.core.RedisMQTemplate; +import com.iailab.framework.mq.redis.core.job.RedisPendingMessageResendJob; +import com.iailab.framework.mq.redis.core.pubsub.AbstractRedisChannelMessageListener; +import com.iailab.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener; +import com.iailab.framework.redis.config.IailabRedisAutoConfiguration; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisServerCommands; +import org.springframework.data.redis.connection.stream.Consumer; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.connection.stream.ReadOffset; +import org.springframework.data.redis.connection.stream.StreamOffset; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.stream.StreamMessageListenerContainer; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.List; +import java.util.Properties; + +/** + * Redis 消息队列 Consumer 配置类 + * + * @author iailab + */ +@Slf4j +@EnableScheduling // 启用定时任务,用于 RedisPendingMessageResendJob 重发消息 +@AutoConfiguration(after = IailabRedisAutoConfiguration.class) +public class IailabRedisMQConsumerAutoConfiguration { + + /** + * 创建 Redis Pub/Sub 广播消费的容器 + */ + @Bean + @ConditionalOnBean(AbstractRedisChannelMessageListener.class) // 只有 AbstractChannelMessageListener 存在的时候,才需要注册 Redis pubsub 监听 + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisMQTemplate redisMQTemplate, List<AbstractRedisChannelMessageListener<?>> listeners) { + // 创建 RedisMessageListenerContainer 对象 + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + // 设置 RedisConnection 工厂。 + container.setConnectionFactory(redisMQTemplate.getRedisTemplate().getRequiredConnectionFactory()); + // 添加监听器 + listeners.forEach(listener -> { + listener.setRedisMQTemplate(redisMQTemplate); + container.addMessageListener(listener, new ChannelTopic(listener.getChannel())); + log.info("[redisMessageListenerContainer][注册 Channel({}) 对应的监听器({})]", + listener.getChannel(), listener.getClass().getName()); + }); + return container; + } + + /** + * 创建 Redis Stream 重新消费的任务 + */ + @Bean + @ConditionalOnBean(AbstractRedisStreamMessageListener.class) // 只有 AbstractStreamMessageListener 存在的时候,才需要注册 Redis pubsub 监听 + public RedisPendingMessageResendJob redisPendingMessageResendJob(List<AbstractRedisStreamMessageListener<?>> listeners, + RedisMQTemplate redisTemplate, + @Value("${spring.application.name}") String groupName, + RedissonClient redissonClient) { + return new RedisPendingMessageResendJob(listeners, redisTemplate, groupName, redissonClient); + } + + /** + * 创建 Redis Stream 集群消费的容器 + * + * 基础知识:<a href="https://www.geek-book.com/src/docs/redis/redis/redis.io/commands/xreadgroup.html">Redis Stream 的 xreadgroup 命令</a> + */ + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnBean(AbstractRedisStreamMessageListener.class) // 只有 AbstractStreamMessageListener 存在的时候,才需要注册 Redis pubsub 监听 + public StreamMessageListenerContainer<String, ObjectRecord<String, String>> redisStreamMessageListenerContainer( + RedisMQTemplate redisMQTemplate, List<AbstractRedisStreamMessageListener<?>> listeners) { + RedisTemplate<String, ?> redisTemplate = redisMQTemplate.getRedisTemplate(); + checkRedisVersion(redisTemplate); + // 第一步,创建 StreamMessageListenerContainer 容器 + // 创建 options 配置 + StreamMessageListenerContainer.StreamMessageListenerContainerOptions<String, ObjectRecord<String, String>> containerOptions = + StreamMessageListenerContainer.StreamMessageListenerContainerOptions.builder() + .batchSize(10) // 一次性最多拉取多少条消息 + .targetType(String.class) // 目标类型。统一使用 String,通过自己封装的 AbstractStreamMessageListener 去反序列化 + .build(); + // 创建 container 对象 + StreamMessageListenerContainer<String, ObjectRecord<String, String>> container = + StreamMessageListenerContainer.create(redisMQTemplate.getRedisTemplate().getRequiredConnectionFactory(), containerOptions); + + // 第二步,注册监听器,消费对应的 Stream 主题 + String consumerName = buildConsumerName(); + listeners.parallelStream().forEach(listener -> { + log.info("[redisStreamMessageListenerContainer][开始注册 StreamKey({}) 对应的监听器({})]", + listener.getStreamKey(), listener.getClass().getName()); + // 创建 listener 对应的消费者分组 + try { + redisTemplate.opsForStream().createGroup(listener.getStreamKey(), listener.getGroup()); + } catch (Exception ignore) { + } + // 设置 listener 对应的 redisTemplate + listener.setRedisMQTemplate(redisMQTemplate); + // 创建 Consumer 对象 + Consumer consumer = Consumer.from(listener.getGroup(), consumerName); + // 设置 Consumer 消费进度,以最小消费进度为准 + StreamOffset<String> streamOffset = StreamOffset.create(listener.getStreamKey(), ReadOffset.lastConsumed()); + // 设置 Consumer 监听 + StreamMessageListenerContainer.StreamReadRequestBuilder<String> builder = StreamMessageListenerContainer.StreamReadRequest + .builder(streamOffset).consumer(consumer) + .autoAcknowledge(false) // 不自动 ack + .cancelOnError(throwable -> false); // 默认配置,发生异常就取消消费,显然不符合预期;因此,我们设置为 false + container.register(builder.build(), listener); + log.info("[redisStreamMessageListenerContainer][完成注册 StreamKey({}) 对应的监听器({})]", + listener.getStreamKey(), listener.getClass().getName()); + }); + return container; + } + + /** + * 构建消费者名字,使用本地 IP + 进程编号的方式。 + * 参考自 RocketMQ clientId 的实现 + * + * @return 消费者名字 + */ + private static String buildConsumerName() { + return String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); + } + + /** + * 校验 Redis 版本号,是否满足最低的版本号要求! + */ + private static void checkRedisVersion(RedisTemplate<String, ?> redisTemplate) { + // 获得 Redis 版本 + Properties info = redisTemplate.execute((RedisCallback<Properties>) RedisServerCommands::info); + String version = MapUtil.getStr(info, "redis_version"); + // 校验最低版本必须大于等于 5.0.0 + int majorVersion = Integer.parseInt(StrUtil.subBefore(version, '.', false)); + if (majorVersion < 5) { + throw new IllegalStateException(StrUtil.format("您当前的 Redis 版本为 {},小于最低要求的 5.0.0 版本!" + + "请参考 {} 文档进行安装。", version, DocumentEnum.REDIS_INSTALL.getUrl())); + } + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQProducerAutoConfiguration.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQProducerAutoConfiguration.java new file mode 100644 index 0000000..249f7f6 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/config/IailabRedisMQProducerAutoConfiguration.java @@ -0,0 +1,31 @@ +package com.iailab.framework.mq.redis.config; + +import com.iailab.framework.mq.redis.core.RedisMQTemplate; +import com.iailab.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.iailab.framework.redis.config.IailabRedisAutoConfiguration; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +/** + * Redis 消息队列 Producer 配置类 + * + * @author iailab + */ +@Slf4j +@AutoConfiguration(after = IailabRedisAutoConfiguration.class) +public class IailabRedisMQProducerAutoConfiguration { + + @Bean + public RedisMQTemplate redisMQTemplate(StringRedisTemplate redisTemplate, + List<RedisMessageInterceptor> interceptors) { + RedisMQTemplate redisMQTemplate = new RedisMQTemplate(redisTemplate); + // 添加拦截器 + interceptors.forEach(redisMQTemplate::addInterceptor); + return redisMQTemplate; + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/RedisMQTemplate.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/RedisMQTemplate.java new file mode 100644 index 0000000..76009f8 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/RedisMQTemplate.java @@ -0,0 +1,87 @@ +package com.iailab.framework.mq.redis.core; + +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.iailab.framework.mq.redis.core.message.AbstractRedisMessage; +import com.iailab.framework.mq.redis.core.pubsub.AbstractRedisChannelMessage; +import com.iailab.framework.mq.redis.core.stream.AbstractRedisStreamMessage; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.redis.connection.stream.RecordId; +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.ArrayList; +import java.util.List; + +/** + * Redis MQ 操作模板类 + * + * @author iailab + */ +@AllArgsConstructor +public class RedisMQTemplate { + + @Getter + private final RedisTemplate<String, ?> redisTemplate; + /** + * 拦截器数组 + */ + @Getter + private final List<RedisMessageInterceptor> interceptors = new ArrayList<>(); + + /** + * 发送 Redis 消息,基于 Redis pub/sub 实现 + * + * @param message 消息 + */ + public <T extends AbstractRedisChannelMessage> void send(T message) { + try { + sendMessageBefore(message); + // 发送消息 + redisTemplate.convertAndSend(message.getChannel(), JsonUtils.toJsonString(message)); + } finally { + sendMessageAfter(message); + } + } + + /** + * 发送 Redis 消息,基于 Redis Stream 实现 + * + * @param message 消息 + * @return 消息记录的编号对象 + */ + public <T extends AbstractRedisStreamMessage> RecordId send(T message) { + try { + sendMessageBefore(message); + // 发送消息 + return redisTemplate.opsForStream().add(StreamRecords.newRecord() + .ofObject(JsonUtils.toJsonString(message)) // 设置内容 + .withStreamKey(message.getStreamKey())); // 设置 stream key + } finally { + sendMessageAfter(message); + } + } + + /** + * 添加拦截器 + * + * @param interceptor 拦截器 + */ + public void addInterceptor(RedisMessageInterceptor interceptor) { + interceptors.add(interceptor); + } + + private void sendMessageBefore(AbstractRedisMessage message) { + // 正序 + interceptors.forEach(interceptor -> interceptor.sendMessageBefore(message)); + } + + private void sendMessageAfter(AbstractRedisMessage message) { + // 倒序 + for (int i = interceptors.size() - 1; i >= 0; i--) { + interceptors.get(i).sendMessageAfter(message); + } + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java new file mode 100644 index 0000000..b3f1eae --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/interceptor/RedisMessageInterceptor.java @@ -0,0 +1,26 @@ +package com.iailab.framework.mq.redis.core.interceptor; + +import com.iailab.framework.mq.redis.core.message.AbstractRedisMessage; + +/** + * {@link AbstractRedisMessage} 消息拦截器 + * 通过拦截器,作为插件机制,实现拓展。 + * 例如说,多租户场景下的 MQ 消息处理 + * + * @author iailab + */ +public interface RedisMessageInterceptor { + + default void sendMessageBefore(AbstractRedisMessage message) { + } + + default void sendMessageAfter(AbstractRedisMessage message) { + } + + default void consumeMessageBefore(AbstractRedisMessage message) { + } + + default void consumeMessageAfter(AbstractRedisMessage message) { + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/job/RedisPendingMessageResendJob.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/job/RedisPendingMessageResendJob.java new file mode 100644 index 0000000..448c63a --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/job/RedisPendingMessageResendJob.java @@ -0,0 +1,100 @@ +package com.iailab.framework.mq.redis.core.job; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.mq.redis.core.RedisMQTemplate; +import com.iailab.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.stream.*; +import org.springframework.data.redis.core.StreamOperations; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * 这个任务用于处理,crash 之后的消费者未消费完的消息 + */ +@Slf4j +@AllArgsConstructor +public class RedisPendingMessageResendJob { + + private static final String LOCK_KEY = "redis:pending:msg:lock"; + + /** + * 消息超时时间,默认 5 分钟 + * + * 1. 超时的消息才会被重新投递 + * 2. 由于定时任务 1 分钟一次,消息超时后不会被立即重投,极端情况下消息5分钟过期后,再等 1 分钟才会被扫瞄到 + */ + private static final int EXPIRE_TIME = 5 * 60; + + private final List<AbstractRedisStreamMessageListener<?>> listeners; + private final RedisMQTemplate redisTemplate; + private final String groupName; + private final RedissonClient redissonClient; + + /** + * 一分钟执行一次,这里选择每分钟的35秒执行,是为了避免整点任务过多的问题 + */ + @Scheduled(cron = "35 * * * * ?") + public void messageResend() { + RLock lock = redissonClient.getLock(LOCK_KEY); + // 尝试加锁 + if (lock.tryLock()) { + try { + execute(); + } catch (Exception ex) { + log.error("[messageResend][执行异常]", ex); + } finally { + lock.unlock(); + } + } + } + + /** + * 执行清理逻辑 + * + * @see <a href="https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/480/files">讨论</a> + */ + private void execute() { + StreamOperations<String, Object, Object> ops = redisTemplate.getRedisTemplate().opsForStream(); + listeners.forEach(listener -> { + PendingMessagesSummary pendingMessagesSummary = Objects.requireNonNull(ops.pending(listener.getStreamKey(), groupName)); + // 每个消费者的 pending 队列消息数量 + Map<String, Long> pendingMessagesPerConsumer = pendingMessagesSummary.getPendingMessagesPerConsumer(); + pendingMessagesPerConsumer.forEach((consumerName, pendingMessageCount) -> { + log.info("[processPendingMessage][消费者({}) 消息数量({})]", consumerName, pendingMessageCount); + // 每个消费者的 pending消息的详情信息 + PendingMessages pendingMessages = ops.pending(listener.getStreamKey(), Consumer.from(groupName, consumerName), Range.unbounded(), pendingMessageCount); + if (pendingMessages.isEmpty()) { + return; + } + pendingMessages.forEach(pendingMessage -> { + // 获取消息上一次传递到 consumer 的时间, + long lastDelivery = pendingMessage.getElapsedTimeSinceLastDelivery().getSeconds(); + if (lastDelivery < EXPIRE_TIME){ + return; + } + // 获取指定 id 的消息体 + List<MapRecord<String, Object, Object>> records = ops.range(listener.getStreamKey(), + Range.of(Range.Bound.inclusive(pendingMessage.getIdAsString()), Range.Bound.inclusive(pendingMessage.getIdAsString()))); + if (CollUtil.isEmpty(records)) { + return; + } + // 重新投递消息 + redisTemplate.getRedisTemplate().opsForStream().add(StreamRecords.newRecord() + .ofObject(records.get(0).getValue()) // 设置内容 + .withStreamKey(listener.getStreamKey())); + // ack 消息消费完成 + redisTemplate.getRedisTemplate().opsForStream().acknowledge(groupName, records.get(0)); + log.info("[processPendingMessage][消息({})重新投递成功]", records.get(0).getId()); + }); + }); + }); + } +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/message/AbstractRedisMessage.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/message/AbstractRedisMessage.java new file mode 100644 index 0000000..46b3f88 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/message/AbstractRedisMessage.java @@ -0,0 +1,29 @@ +package com.iailab.framework.mq.redis.core.message; + +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; + +/** + * Redis 消息抽象基类 + * + * @author iailab + */ +@Data +public abstract class AbstractRedisMessage { + + /** + * 头 + */ + private Map<String, String> headers = new HashMap<>(); + + public String getHeader(String key) { + return headers.get(key); + } + + public void addHeader(String key, String value) { + headers.put(key, value); + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java new file mode 100644 index 0000000..e95d622 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessage.java @@ -0,0 +1,23 @@ +package com.iailab.framework.mq.redis.core.pubsub; + +import com.iailab.framework.mq.redis.core.message.AbstractRedisMessage; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Redis Channel Message 抽象类 + * + * @author iailab + */ +public abstract class AbstractRedisChannelMessage extends AbstractRedisMessage { + + /** + * 获得 Redis Channel,默认使用类名 + * + * @return Channel + */ + @JsonIgnore // 避免序列化。原因是,Redis 发布 Channel 消息的时候,已经会指定。 + public String getChannel() { + return getClass().getSimpleName(); + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java new file mode 100644 index 0000000..f0f2578 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/pubsub/AbstractRedisChannelMessageListener.java @@ -0,0 +1,103 @@ +package com.iailab.framework.mq.redis.core.pubsub; + +import cn.hutool.core.util.TypeUtil; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.mq.redis.core.RedisMQTemplate; +import com.iailab.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.iailab.framework.mq.redis.core.message.AbstractRedisMessage; +import lombok.Setter; +import lombok.SneakyThrows; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; + +import java.lang.reflect.Type; +import java.util.List; + +/** + * Redis Pub/Sub 监听器抽象类,用于实现广播消费 + * + * @param <T> 消息类型。一定要填写噢,不然会报错 + * + * @author iailab + */ +public abstract class AbstractRedisChannelMessageListener<T extends AbstractRedisChannelMessage> implements MessageListener { + + /** + * 消息类型 + */ + private final Class<T> messageType; + /** + * Redis Channel + */ + private final String channel; + /** + * RedisMQTemplate + */ + @Setter + private RedisMQTemplate redisMQTemplate; + + @SneakyThrows + protected AbstractRedisChannelMessageListener() { + this.messageType = getMessageClass(); + this.channel = messageType.getDeclaredConstructor().newInstance().getChannel(); + } + + /** + * 获得 Sub 订阅的 Redis Channel 通道 + * + * @return channel + */ + public final String getChannel() { + return channel; + } + + @Override + public final void onMessage(Message message, byte[] bytes) { + T messageObj = JsonUtils.parseObject(message.getBody(), messageType); + try { + consumeMessageBefore(messageObj); + // 消费消息 + this.onMessage(messageObj); + } finally { + consumeMessageAfter(messageObj); + } + } + + /** + * 处理消息 + * + * @param message 消息 + */ + public abstract void onMessage(T message); + + /** + * 通过解析类上的泛型,获得消息类型 + * + * @return 消息类型 + */ + @SuppressWarnings("unchecked") + private Class<T> getMessageClass() { + Type type = TypeUtil.getTypeArgument(getClass(), 0); + if (type == null) { + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); + } + return (Class<T>) type; + } + + private void consumeMessageBefore(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List<RedisMessageInterceptor> interceptors = redisMQTemplate.getInterceptors(); + // 正序 + interceptors.forEach(interceptor -> interceptor.consumeMessageBefore(message)); + } + + private void consumeMessageAfter(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List<RedisMessageInterceptor> interceptors = redisMQTemplate.getInterceptors(); + // 倒序 + for (int i = interceptors.size() - 1; i >= 0; i--) { + interceptors.get(i).consumeMessageAfter(message); + } + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java new file mode 100644 index 0000000..05743c8 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessage.java @@ -0,0 +1,23 @@ +package com.iailab.framework.mq.redis.core.stream; + +import com.iailab.framework.mq.redis.core.message.AbstractRedisMessage; +import com.fasterxml.jackson.annotation.JsonIgnore; + +/** + * Redis Stream Message 抽象类 + * + * @author iailab + */ +public abstract class AbstractRedisStreamMessage extends AbstractRedisMessage { + + /** + * 获得 Redis Stream Key,默认使用类名 + * + * @return Channel + */ + @JsonIgnore // 避免序列化 + public String getStreamKey() { + return getClass().getSimpleName(); + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java new file mode 100644 index 0000000..6628611 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/core/stream/AbstractRedisStreamMessageListener.java @@ -0,0 +1,113 @@ +package com.iailab.framework.mq.redis.core.stream; + +import cn.hutool.core.util.TypeUtil; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.mq.redis.core.RedisMQTemplate; +import com.iailab.framework.mq.redis.core.interceptor.RedisMessageInterceptor; +import com.iailab.framework.mq.redis.core.message.AbstractRedisMessage; +import lombok.Getter; +import lombok.Setter; +import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.stream.ObjectRecord; +import org.springframework.data.redis.stream.StreamListener; + +import java.lang.reflect.Type; +import java.util.List; + +/** + * Redis Stream 监听器抽象类,用于实现集群消费 + * + * @param <T> 消息类型。一定要填写噢,不然会报错 + * + * @author iailab + */ +public abstract class AbstractRedisStreamMessageListener<T extends AbstractRedisStreamMessage> + implements StreamListener<String, ObjectRecord<String, String>> { + + /** + * 消息类型 + */ + private final Class<T> messageType; + /** + * Redis Channel + */ + @Getter + private final String streamKey; + + /** + * Redis 消费者分组,默认使用 spring.application.name 名字 + */ + @Value("${spring.application.name}") + @Getter + private String group; + /** + * RedisMQTemplate + */ + @Setter + private RedisMQTemplate redisMQTemplate; + + @SneakyThrows + protected AbstractRedisStreamMessageListener() { + this.messageType = getMessageClass(); + this.streamKey = messageType.getDeclaredConstructor().newInstance().getStreamKey(); + } + + @Override + public void onMessage(ObjectRecord<String, String> message) { + // 消费消息 + T messageObj = JsonUtils.parseObject(message.getValue(), messageType); + try { + consumeMessageBefore(messageObj); + // 消费消息 + this.onMessage(messageObj); + // ack 消息消费完成 + redisMQTemplate.getRedisTemplate().opsForStream().acknowledge(group, message); + // TODO iailab:需要额外考虑以下几个点: + // 1. 处理异常的情况 + // 2. 发送日志;以及事务的结合 + // 3. 消费日志;以及通用的幂等性 + // 4. 消费失败的重试,https://zhuanlan.zhihu.com/p/60501638 + } finally { + consumeMessageAfter(messageObj); + } + } + + /** + * 处理消息 + * + * @param message 消息 + */ + public abstract void onMessage(T message); + + /** + * 通过解析类上的泛型,获得消息类型 + * + * @return 消息类型 + */ + @SuppressWarnings("unchecked") + private Class<T> getMessageClass() { + Type type = TypeUtil.getTypeArgument(getClass(), 0); + if (type == null) { + throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName())); + } + return (Class<T>) type; + } + + private void consumeMessageBefore(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List<RedisMessageInterceptor> interceptors = redisMQTemplate.getInterceptors(); + // 正序 + interceptors.forEach(interceptor -> interceptor.consumeMessageBefore(message)); + } + + private void consumeMessageAfter(AbstractRedisMessage message) { + assert redisMQTemplate != null; + List<RedisMessageInterceptor> interceptors = redisMQTemplate.getInterceptors(); + // 倒序 + for (int i = interceptors.size() - 1; i >= 0; i--) { + interceptors.get(i).consumeMessageAfter(message); + } + } + +} diff --git a/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/package-info.java b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/package-info.java new file mode 100644 index 0000000..7a386a9 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/java/com/iailab/framework/mq/redis/package-info.java @@ -0,0 +1,6 @@ +/** + * 消息队列,基于 Redis 提供: + * 1. 基于 Pub/Sub 实现广播消费 + * 2. 基于 Stream 实现集群消费 + */ +package com.iailab.framework.mq.redis; diff --git a/iailab-framework/iailab-common-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..c38ac54 --- /dev/null +++ b/iailab-framework/iailab-common-mq/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +com.iailab.framework.mq.redis.config.IailabRedisMQProducerAutoConfiguration +com.iailab.framework.mq.redis.config.IailabRedisMQConsumerAutoConfiguration +com.iailab.framework.mq.rabbitmq.config.IailabRabbitMQAutoConfiguration diff --git a/iailab-framework/iailab-common-mybatis/pom.xml b/iailab-framework/iailab-common-mybatis/pom.xml new file mode 100644 index 0000000..9aa8d58 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/pom.xml @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-mybatis</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>数据库连接池、多数据源、事务、MyBatis 拓展</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-web</artifactId> + <scope>provided</scope> <!-- 设置为 provided,只有 OncePerRequestFilter 使用到 --> + </dependency> + + <!-- DB 相关 --> + <dependency> + <groupId>com.mysql</groupId> + <artifactId>mysql-connector-j</artifactId> + </dependency> + <dependency> + <groupId>com.oracle.database.jdbc</groupId> + <artifactId>ojdbc8</artifactId> +<!-- <optional>true</optional>--> + </dependency> + <dependency> + <groupId>org.postgresql</groupId> + <artifactId>postgresql</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>com.microsoft.sqlserver</groupId> + <artifactId>mssql-jdbc</artifactId> + <optional>true</optional> + </dependency> + <dependency> + <groupId>com.dameng</groupId> + <artifactId>DmJdbcDriver18</artifactId> + <optional>true</optional> + </dependency> + + <dependency> + <groupId>com.alibaba</groupId> + <artifactId>druid-spring-boot-starter</artifactId> + </dependency> + <dependency> + <groupId>com.baomidou</groupId> + <artifactId>mybatis-plus-boot-starter</artifactId> + </dependency> + <dependency> + <groupId>com.baomidou</groupId> + <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <!-- 多数据源 --> + </dependency> + + <dependency> + <groupId>com.github.yulichang</groupId> + <artifactId>mybatis-plus-join-boot-starter</artifactId> <!-- MyBatis 联表查询 --> + </dependency> + + <dependency> + <groupId>com.fhs-opensource</groupId> <!-- VO 数据翻译 --> + <artifactId>easy-trans-spring-boot-starter</artifactId> + </dependency> + <dependency> + <groupId>com.fhs-opensource</groupId> + <artifactId>easy-trans-mybatis-plus-extend</artifactId> + </dependency> + <dependency> + <groupId>org.testng</groupId> + <artifactId>testng</artifactId> + <version>RELEASE</version> + <scope>compile</scope> + </dependency> + <dependency> + <groupId>com.alibaba.nacos</groupId> + <artifactId>nacos-client</artifactId> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/dao/BaseDao.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/dao/BaseDao.java new file mode 100644 index 0000000..0cdb369 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/dao/BaseDao.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +/** + * 基础Dao + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public interface BaseDao<T> extends BaseMapper<T> { + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/entity/BaseEntity.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/entity/BaseEntity.java new file mode 100644 index 0000000..1a740ce --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/entity/BaseEntity.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.entity; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 基础实体类,所有实体都需要继承 + * + * @author Mark sunlightcs@gmail.com + */ +@Data +public abstract class BaseEntity implements Serializable { + /** + * id + */ + @TableId + private Long id; + /** + * 创建者 + */ + @TableField(fill = FieldFill.INSERT) + private Long creator; + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private Date createDate; +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/package-info.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/package-info.java new file mode 100644 index 0000000..44e33fb --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/package-info.java @@ -0,0 +1 @@ +package com.iailab.framework.common; \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/page/PageData.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/page/PageData.java new file mode 100644 index 0000000..64c0822 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/page/PageData.java @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.page; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.Data; + +import java.io.Serializable; +import java.util.List; + +/** + * 分页工具类 + * + * @author Mark sunlightcs@gmail.com + */ +@Data +@Tag(name = "分页数据") +public class PageData<T> implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "总记录数") + private int total; + + @Schema(description = "列表数据") + private List<T> list; + + /** + * 分页 + * @param list 列表数据 + * @param total 总记录数 + */ + public PageData(List<T> list, long total) { + this.list = list; + this.total = (int)total; + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/BaseService.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/BaseService.java new file mode 100644 index 0000000..686441d --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/BaseService.java @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; + +import java.io.Serializable; +import java.util.Collection; + +/** + * 基础服务接口,所有Service接口都要继承 + * + * @author Mark sunlightcs@gmail.com + */ +public interface BaseService<T> { + Class<T> currentModelClass(); + + /** + * <p> + * 插入一条记录(选择字段,策略插入) + * </p> + * + * @param entity 实体对象 + */ + boolean insert(T entity); + + /** + * <p> + * 插入(批量),该方法不支持 Oracle、SQL Server + * </p> + * + * @param entityList 实体对象集合 + */ + boolean insertBatch(Collection<T> entityList); + + /** + * <p> + * 插入(批量),该方法不支持 Oracle、SQL Server + * </p> + * + * @param entityList 实体对象集合 + * @param batchSize 插入批次数量 + */ + boolean insertBatch(Collection<T> entityList, int batchSize); + + /** + * <p> + * 根据 ID 选择修改 + * </p> + * + * @param entity 实体对象 + */ + boolean updateById(T entity); + + /** + * <p> + * 根据 whereEntity 条件,更新记录 + * </p> + * + * @param entity 实体对象 + * @param updateWrapper 实体对象封装操作类 {@link com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper} + */ + boolean update(T entity, Wrapper<T> updateWrapper); + + /** + * <p> + * 根据ID 批量更新 + * </p> + * + * @param entityList 实体对象集合 + */ + boolean updateBatchById(Collection<T> entityList); + + /** + * <p> + * 根据ID 批量更新 + * </p> + * + * @param entityList 实体对象集合 + * @param batchSize 更新批次数量 + */ + boolean updateBatchById(Collection<T> entityList, int batchSize); + + /** + * <p> + * 根据 ID 查询 + * </p> + * + * @param id 主键ID + */ + T selectById(Serializable id); + + /** + * <p> + * 根据 ID 删除 + * </p> + * + * @param id 主键ID + */ + boolean deleteById(Serializable id); + + /** + * <p> + * 删除(根据ID 批量删除) + * </p> + * + * @param idList 主键ID列表 + */ + boolean deleteBatchIds(Collection<? extends Serializable> idList); +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/CrudService.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/CrudService.java new file mode 100644 index 0000000..c8c78cb --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/CrudService.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * <p> + * https://www.renren.io + * <p> + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.service; + +import com.iailab.framework.common.page.PageData; + +import java.util.List; +import java.util.Map; + +/** + * CRUD基础服务接口 + * + * @author Mark sunlightcs@gmail.com + */ +public interface CrudService<T, D> extends BaseService<T> { + + PageData<D> page(Map<String, Object> params); + + List<D> list(Map<String, Object> params); + + D get(Long id); + + void save(D dto); + + void update(D dto); + + void delete(Long[] ids); + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/BaseServiceImpl.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/BaseServiceImpl.java new file mode 100644 index 0000000..51254d7 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/BaseServiceImpl.java @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.service.impl; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.enums.SqlMethod; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import com.baomidou.mybatisplus.core.toolkit.ReflectionKit; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.toolkit.SqlHelper; +import com.iailab.framework.common.constant.Constant; +import com.iailab.framework.common.page.PageData; +import com.iailab.framework.common.service.BaseService; +import com.iailab.framework.common.util.object.BeanUtils; +import org.apache.ibatis.binding.MapperMethod; +import org.apache.ibatis.logging.Log; +import org.apache.ibatis.logging.LogFactory; +import org.apache.ibatis.session.SqlSession; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; + +/** + * 基础服务类,所有Service都要继承 + * + * @author Mark sunlightcs@gmail.com + */ +public abstract class BaseServiceImpl<M extends BaseMapper<T>, T> implements BaseService<T> { + @Autowired + protected M baseDao; + protected Log log = LogFactory.getLog(getClass()); + + /** + * 获取分页对象 + * @param params 分页查询参数 + * @param defaultOrderField 默认排序字段 + * @param isAsc 排序方式 + */ + protected IPage<T> getPage(Map<String, Object> params, String defaultOrderField, boolean isAsc) { + //分页参数 + long curPage = 1; + long limit = 10; + + if(params.get(Constant.PAGE) != null){ + curPage = Long.parseLong((String)params.get(Constant.PAGE)); + } + if(params.get(Constant.LIMIT) != null){ + limit = Long.parseLong((String)params.get(Constant.LIMIT)); + } + + //分页对象 + Page<T> page = new Page<>(curPage, limit); + + //分页参数 + params.put(Constant.PAGE, page); + + //排序字段 + String orderField = (String)params.get(Constant.ORDER_FIELD); + String order = (String)params.get(Constant.ORDER); + + //前端字段排序 + if(StringUtils.isNotBlank(orderField) && StringUtils.isNotBlank(order)){ + if(Constant.ASC.equalsIgnoreCase(order)) { + return page.addOrder(OrderItem.asc(orderField)); + }else { + return page.addOrder(OrderItem.desc(orderField)); + } + } + + //没有排序字段,则不排序 + if(StringUtils.isBlank(defaultOrderField)){ + return page; + } + + //默认排序 + if(isAsc) { + page.addOrder(OrderItem.asc(defaultOrderField)); + }else { + page.addOrder(OrderItem.desc(defaultOrderField)); + } + + return page; + } + + protected <T> PageData<T> getPageData(List<?> list, long total, Class<T> target){ + List<T> targetList = BeanUtils.toBean(list, target); + + return new PageData<>(targetList, total); + } + + protected <T> PageData<T> getPageData(IPage page, Class<T> target){ + return getPageData(page.getRecords(), page.getTotal(), target); + } + + protected void paramsToLike(Map<String, Object> params, String... likes){ + for (String like : likes){ + String val = (String)params.get(like); + if (StringUtils.isNotBlank(val)){ + params.put(like, "%" + val + "%"); + }else { + params.put(like, null); + } + } + } + + /** + * <p> + * 判断数据库操作是否成功 + * </p> + * <p> + * 注意!! 该方法为 Integer 判断,不可传入 int 基本类型 + * </p> + * + * @param result 数据库操作返回影响条数 + * @return boolean + */ + protected static boolean retBool(Integer result) { + return SqlHelper.retBool(result); + } + + protected Class<M> currentMapperClass() { + return (Class<M>) ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 0); + } + + @Override + public Class<T> currentModelClass() { + return (Class<T>)ReflectionKit.getSuperClassGenericType(this.getClass(), BaseServiceImpl.class, 1); + } + + protected String getSqlStatement(SqlMethod sqlMethod) { + return SqlHelper.getSqlStatement(this.currentMapperClass(), sqlMethod); + } + + @Override + public boolean insert(T entity) { + return BaseServiceImpl.retBool(baseDao.insert(entity)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean insertBatch(Collection<T> entityList) { + return insertBatch(entityList, 100); + } + + /** + * 批量插入 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean insertBatch(Collection<T> entityList, int batchSize) { + String sqlStatement = getSqlStatement(SqlMethod.INSERT_ONE); + return executeBatch(entityList, batchSize, (sqlSession, entity) -> sqlSession.insert(sqlStatement, entity)); + } + + /** + * 执行批量操作 + */ + protected <E> boolean executeBatch(Collection<E> list, int batchSize, BiConsumer<SqlSession, E> consumer) { + return SqlHelper.executeBatch(this.currentModelClass(), this.log, list, batchSize, consumer); + } + + + @Override + public boolean updateById(T entity) { + return BaseServiceImpl.retBool(baseDao.updateById(entity)); + } + + @Override + public boolean update(T entity, Wrapper<T> updateWrapper) { + return BaseServiceImpl.retBool(baseDao.update(entity, updateWrapper)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateBatchById(Collection<T> entityList) { + return updateBatchById(entityList, 30); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateBatchById(Collection<T> entityList, int batchSize) { + String sqlStatement = getSqlStatement(SqlMethod.UPDATE_BY_ID); + return executeBatch(entityList, batchSize, (sqlSession, entity) -> { + MapperMethod.ParamMap<T> param = new MapperMethod.ParamMap<>(); + param.put(Constants.ENTITY, entity); + sqlSession.update(sqlStatement, param); + }); + } + + @Override + public T selectById(Serializable id) { + return baseDao.selectById(id); + } + + @Override + public boolean deleteById(Serializable id) { + return SqlHelper.retBool(baseDao.deleteById(id)); + } + + @Override + public boolean deleteBatchIds(Collection<? extends Serializable> idList) { + return SqlHelper.retBool(baseDao.deleteBatchIds(idList)); + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/CrudServiceImpl.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/CrudServiceImpl.java new file mode 100644 index 0000000..6b12a43 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/common/service/impl/CrudServiceImpl.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * <p> + * https://www.renren.io + * <p> + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.ReflectionKit; +import com.iailab.framework.common.page.PageData; +import com.iailab.framework.common.service.CrudService; +import com.iailab.framework.common.util.object.ConvertUtils; +import org.springframework.beans.BeanUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * CRUD基础服务类 + * + * @author Mark sunlightcs@gmail.com + */ +public abstract class CrudServiceImpl<M extends BaseMapper<T>, T, D> extends BaseServiceImpl<M, T> implements CrudService<T, D> { + + protected Class<D> currentDtoClass() { + return (Class<D>)ReflectionKit.getSuperClassGenericType(getClass(), CrudServiceImpl.class, 2); + } + + @Override + public PageData<D> page(Map<String, Object> params) { + IPage<T> page = baseDao.selectPage( + getPage(params, null, false), + getWrapper(params) + ); + + return getPageData(page, currentDtoClass()); + } + + @Override + public List<D> list(Map<String, Object> params) { + List<T> entityList = baseDao.selectList(getWrapper(params)); + + return ConvertUtils.sourceToTarget(entityList, currentDtoClass()); + } + + public abstract QueryWrapper<T> getWrapper(Map<String, Object> params); + + @Override + public D get(Long id) { + T entity = baseDao.selectById(id); + + return ConvertUtils.sourceToTarget(entity, currentDtoClass()); + } + + @Override + public void save(D dto) { + T entity = ConvertUtils.sourceToTarget(dto, currentModelClass()); + insert(entity); + + //copy主键值到dto + BeanUtils.copyProperties(entity, dto); + } + + @Override + public void update(D dto) { + T entity = ConvertUtils.sourceToTarget(dto, currentModelClass()); + updateById(entity); + } + + @Override + public void delete(Long[] ids) { + baseDao.deleteBatchIds(Arrays.asList(ids)); + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/config/IailabDataSourceAutoConfiguration.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/config/IailabDataSourceAutoConfiguration.java new file mode 100644 index 0000000..27d252d --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/config/IailabDataSourceAutoConfiguration.java @@ -0,0 +1,40 @@ +package com.iailab.framework.datasource.config; + +import com.iailab.framework.datasource.core.filter.DruidAdRemoveFilter; +import com.alibaba.druid.spring.boot.autoconfigure.properties.DruidStatProperties; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * 数据库配置类 + * + * @author iailab + */ +@AutoConfiguration +@EnableTransactionManagement(proxyTargetClass = true) // 启动事务管理 +@EnableConfigurationProperties(DruidStatProperties.class) +public class IailabDataSourceAutoConfiguration { + + /** + * 创建 DruidAdRemoveFilter 过滤器,过滤 common.js 的广告 + */ + @Bean + @ConditionalOnProperty(name = "spring.datasource.druid.stat-view-servlet.enabled", havingValue = "true") + public FilterRegistrationBean<DruidAdRemoveFilter> druidAdRemoveFilterFilter(DruidStatProperties properties) { + // 获取 druid web 监控页面的参数 + DruidStatProperties.StatViewServlet config = properties.getStatViewServlet(); + // 提取 common.js 的配置路径 + String pattern = config.getUrlPattern() != null ? config.getUrlPattern() : "/druid/*"; + String commonJsPattern = pattern.replaceAll("\\*", "js/common.js"); + // 创建 DruidAdRemoveFilter Bean + FilterRegistrationBean<DruidAdRemoveFilter> registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new DruidAdRemoveFilter()); + registrationBean.addUrlPatterns(commonJsPattern); + return registrationBean; + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/enums/DataSourceEnum.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/enums/DataSourceEnum.java new file mode 100644 index 0000000..0617e5e --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/enums/DataSourceEnum.java @@ -0,0 +1,22 @@ +package com.iailab.framework.datasource.core.enums; + +/** + * 对应于多数据源中不同数据源配置 + * + * 通过在方法上,使用 {@link com.baomidou.dynamic.datasource.annotation.DS} 注解,设置使用的数据源。 + * 注意,默认是 {@link #MASTER} 数据源 + * + * 对应官方文档为 http://dynamic-datasource.com/guide/customize/Annotation.html + */ +public interface DataSourceEnum { + + /** + * 主库,推荐使用 {@link com.baomidou.dynamic.datasource.annotation.Master} 注解 + */ + String MASTER = "master"; + /** + * 从库,推荐使用 {@link com.baomidou.dynamic.datasource.annotation.Slave} 注解 + */ + String SLAVE = "slave"; + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/filter/DruidAdRemoveFilter.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/filter/DruidAdRemoveFilter.java new file mode 100644 index 0000000..cd54d0c --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/core/filter/DruidAdRemoveFilter.java @@ -0,0 +1,38 @@ +package com.iailab.framework.datasource.core.filter; + +import com.alibaba.druid.util.Utils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Druid 底部广告过滤器 + * + * @author iailab + */ +public class DruidAdRemoveFilter extends OncePerRequestFilter { + + /** + * common.js 的路径 + */ + private static final String COMMON_JS_ILE_PATH = "support/http/resources/js/common.js"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + chain.doFilter(request, response); + // 重置缓冲区,响应头不会被重置 + response.resetBuffer(); + // 获取 common.js + String text = Utils.readFromResource(COMMON_JS_ILE_PATH); + // 正则替换 banner, 除去底部的广告信息 + text = text.replaceAll("<a.*?banner\"></a><br/>", ""); + text = text.replaceAll("powered.*?shrek.wang</a>", ""); + response.getWriter().write(text); + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/package-info.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/package-info.java new file mode 100644 index 0000000..fa02238 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/datasource/package-info.java @@ -0,0 +1,5 @@ +/** + * 数据库连接池,采用 Druid + * 多数据源,采用爆米花 + */ +package com.iailab.framework.datasource; diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IailabMybatisAutoConfiguration.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IailabMybatisAutoConfiguration.java new file mode 100644 index 0000000..5ee2990 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IailabMybatisAutoConfiguration.java @@ -0,0 +1,65 @@ +package com.iailab.framework.mybatis.config; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.mybatis.core.enums.SqlConstants; +import com.iailab.framework.mybatis.core.handler.DefaultDBFieldHandler; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator; +import com.baomidou.mybatisplus.extension.incrementer.*; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.apache.ibatis.annotations.Mapper; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.ConfigurableEnvironment; + +/** + * MyBaits 配置类 + * + * @author iailab + */ +@AutoConfiguration(before = MybatisPlusAutoConfiguration.class) // 目的:先于 MyBatis Plus 自动配置,避免 @MapperScan 可能扫描不到 Mapper 打印 warn 日志 +@MapperScan(value = "${iailab.info.base-package}", annotationClass = Mapper.class, + lazyInitialization = "${mybatis.lazy-initialization:false}") // Mapper 懒加载,目前仅用于单元测试 +public class IailabMybatisAutoConfiguration { + + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor() { + MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); + mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); // 分页插件 + return mybatisPlusInterceptor; + } + + @Bean + public MetaObjectHandler defaultMetaObjectHandler(){ + return new DefaultDBFieldHandler(); // 自动填充参数类 + } + + @Bean + @ConditionalOnProperty(prefix = "mybatis-plus.global-config.db-config", name = "id-type", havingValue = "INPUT") + public IKeyGenerator keyGenerator(ConfigurableEnvironment environment) { + DbType dbType = IdTypeEnvironmentPostProcessor.getDbType(environment); + if (dbType != null) { + switch (dbType) { + case POSTGRE_SQL: + return new PostgreKeyGenerator(); + case ORACLE: + case ORACLE_12C: + return new OracleKeyGenerator(); + case H2: + return new H2KeyGenerator(); + case KINGBASE_ES: + return new KingbaseKeyGenerator(); + case DM: + return new DmKeyGenerator(); + } + } + // 找不到合适的 IKeyGenerator 实现类 + throw new IllegalArgumentException(StrUtil.format("DbType{} 找不到合适的 IKeyGenerator 实现类", dbType)); + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java new file mode 100644 index 0000000..16ae754 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/IdTypeEnvironmentPostProcessor.java @@ -0,0 +1,106 @@ +package com.iailab.framework.mybatis.config; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.util.collection.SetUtils; +import com.iailab.framework.mybatis.core.enums.SqlConstants; +import com.iailab.framework.mybatis.core.util.JdbcUtils; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.annotation.IdType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.env.EnvironmentPostProcessor; +import org.springframework.core.env.ConfigurableEnvironment; + +import java.util.Set; + +/** + * 当 IdType 为 {@link IdType#NONE} 时,根据 PRIMARY 数据源所使用的数据库,自动设置 + * + * @author iailab + */ +@Slf4j +public class IdTypeEnvironmentPostProcessor implements EnvironmentPostProcessor { + + private static final String ID_TYPE_KEY = "mybatis-plus.global-config.db-config.id-type"; + + private static final String DATASOURCE_DYNAMIC_KEY = "spring.datasource.dynamic"; + + private static final String QUARTZ_JOB_STORE_DRIVER_KEY = "spring.quartz.properties.org.quartz.jobStore.driverDelegateClass"; + + private static final Set<DbType> INPUT_ID_TYPES = SetUtils.asSet(DbType.ORACLE, DbType.ORACLE_12C, + DbType.POSTGRE_SQL, DbType.KINGBASE_ES, DbType.DB2, DbType.H2); + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { + // 如果获取不到 DbType,则不进行处理 + DbType dbType = getDbType(environment); + if (dbType == null) { + return; + } + + // 设置 Quartz JobStore 对应的 Driver + // TODO iailab:暂时没有找到特别合适的地方,先放在这里 + setJobStoreDriverIfPresent(environment, dbType); + // 初始化 SQL 静态变量 + SqlConstants.init(dbType); + // 如果非 NONE,则不进行处理 + IdType idType = getIdType(environment); + if (idType != IdType.NONE) { + return; + } + // 情况一,用户输入 ID,适合 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库 + if (INPUT_ID_TYPES.contains(dbType)) { + setIdType(environment, IdType.INPUT); + return; + } + // 情况二,自增 ID,适合 MySQL 等直接自增的数据库 + setIdType(environment, IdType.AUTO); + } + + public IdType getIdType(ConfigurableEnvironment environment) { + return environment.getProperty(ID_TYPE_KEY, IdType.class); + } + + public void setIdType(ConfigurableEnvironment environment, IdType idType) { + environment.getSystemProperties().put(ID_TYPE_KEY, idType); + log.info("[setIdType][修改 MyBatis Plus 的 idType 为({})]", idType); + } + + public void setJobStoreDriverIfPresent(ConfigurableEnvironment environment, DbType dbType) { + String driverClass = environment.getProperty(QUARTZ_JOB_STORE_DRIVER_KEY); + if (StrUtil.isNotEmpty(driverClass)) { + return; + } + // 根据 dbType 类型,获取对应的 driverClass + switch (dbType) { + case POSTGRE_SQL: + driverClass = "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate"; + break; + case ORACLE: + case ORACLE_12C: + driverClass = "org.quartz.impl.jdbcjobstore.oracle.OracleDelegate"; + break; + case SQL_SERVER: + case SQL_SERVER2005: + driverClass = "org.quartz.impl.jdbcjobstore.MSSQLDelegate"; + break; + } + // 设置 driverClass 变量 + if (StrUtil.isNotEmpty(driverClass)) { + environment.getSystemProperties().put(QUARTZ_JOB_STORE_DRIVER_KEY, driverClass); + } + } + + public static DbType getDbType(ConfigurableEnvironment environment) { + String primary = environment.getProperty(DATASOURCE_DYNAMIC_KEY + "." + "primary"); + if (StrUtil.isEmpty(primary)) { + return null; + } + String url = environment.getProperty(DATASOURCE_DYNAMIC_KEY + ".datasource." + primary + ".url"); + if (StrUtil.isEmpty(url)) { + return null; + } + return JdbcUtils.getDbType(url); + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/MyBatisConfiguration.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/MyBatisConfiguration.java new file mode 100644 index 0000000..e841574 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/config/MyBatisConfiguration.java @@ -0,0 +1,84 @@ +//package com.iailab.framework.mybatis.config; +// +//import com.alibaba.fastjson.JSONArray; +//import com.alibaba.fastjson.JSONObject; +//import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +//import com.baomidou.mybatisplus.extension.plugins.inner.BlockAttackInnerInterceptor; +//import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; +//import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +//import com.iailab.framework.mybatis.core.handler.MybatisHandler; +//import com.iailab.framework.mybatis.interceptor.DataFilterInterceptor; +//import org.apache.ibatis.session.SqlSessionFactory; +//import org.apache.ibatis.type.JdbcType; +//import org.mybatis.spring.SqlSessionFactoryBean; +//import org.mybatis.spring.SqlSessionTemplate; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.boot.jdbc.DataSourceBuilder; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +//import org.springframework.context.annotation.Primary; +//import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +//import org.springframework.jdbc.datasource.DataSourceTransactionManager; +// +// +///** +// * @author houzhongjian +// * @Title: MyBatisConfiguration +// * @ProjectName design-parent +// * @Description: 解决独立启动某个服务时报错问题 No typehandler found for property sqlSessionTemplate +// * @date 2024/7/2 16:35 +// */ +//@Configuration +//public class MyBatisConfiguration { +// +// // 配置mapper的扫描,找到所有的mapper.xml映射文件 +//// @Value("${iailab.info.base-package}") +// @Value("${mybatis-plus.mapper-locations}") +// private String mapperLocations; +// +// @Bean +// @Primary +// public SqlSessionFactory sqlSessionFactory() throws Exception { +// SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean(); +// sqlSessionFactoryBean.setDataSource(DataSourceBuilder.create().build()); +// sqlSessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(mapperLocations)); +// sqlSessionFactoryBean.setConfiguration(buildConfiguration()); +// return sqlSessionFactoryBean.getObject(); +// } +// +// @Bean +// @Primary +// public SqlSessionTemplate sqlSessionTemplate() throws Exception { +// return new SqlSessionTemplate(sqlSessionFactory()); +// } +// +// @Bean +// @Primary +// public DataSourceTransactionManager transactionManager() { +// return new DataSourceTransactionManager(DataSourceBuilder.create().build()); +// } +// +// private org.apache.ibatis.session.Configuration buildConfiguration() { +// org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration(); +// configuration.getTypeHandlerRegistry().register(JSONObject.class, JdbcType.VARCHAR, MybatisHandler.class); +// configuration.getTypeHandlerRegistry().register(JSONArray.class, JdbcType.VARCHAR, MybatisHandler.class); +// return configuration; +// } +// +// @Bean +// public MybatisPlusInterceptor mybatisPlusInterceptor() { +// MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor(); +// // 数据权限 +// mybatisPlusInterceptor.addInnerInterceptor(new DataFilterInterceptor()); +// // 分页插件 +// mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor()); +// // 乐观锁 +// mybatisPlusInterceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); +// // 防止全表更新与删除 +// mybatisPlusInterceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); +// +// return mybatisPlusInterceptor; +// } +// +// +//} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/dataobject/BaseDO.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/dataobject/BaseDO.java new file mode 100644 index 0000000..8c97c70 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/dataobject/BaseDO.java @@ -0,0 +1,56 @@ +package com.iailab.framework.mybatis.core.dataobject; + +import com.baomidou.mybatisplus.annotation.FieldFill; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fhs.core.trans.vo.TransPojo; +import lombok.Data; +import org.apache.ibatis.type.JdbcType; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 基础实体对象 + * + * 为什么实现 {@link TransPojo} 接口? + * 因为使用 Easy-Trans TransType.SIMPLE 模式,集成 MyBatis Plus 查询 + * + * @author iailab + */ +@Data +@JsonIgnoreProperties(value = "transMap") // 由于 Easy-Trans 会添加 transMap 属性,避免 Jackson 在 Spring Cache 反序列化报错 +public abstract class BaseDO implements Serializable, TransPojo { + + /** + * 创建时间 + */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + /** + * 最后更新时间 + */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + /** + * 创建者,目前使用 SysUser 的 id 编号 + * + * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 + */ + @TableField(fill = FieldFill.INSERT, jdbcType = JdbcType.VARCHAR) + private String creator; + /** + * 更新者,目前使用 SysUser 的 id 编号 + * + * 使用 String 类型的原因是,未来可能会存在非数值的情况,留好拓展性。 + */ + @TableField(fill = FieldFill.INSERT_UPDATE, jdbcType = JdbcType.VARCHAR) + private String updater; + /** + * 是否删除 + */ + @TableLogic + private Boolean deleted; + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/DbTypeEnum.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/DbTypeEnum.java new file mode 100644 index 0000000..644d1e3 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/DbTypeEnum.java @@ -0,0 +1,84 @@ +package com.iailab.framework.mybatis.core.enums; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.annotation.DbType; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 针对 MyBatis Plus 的 {@link DbType} 增强,补充更多信息 + */ +@Getter +@AllArgsConstructor +public enum DbTypeEnum { + + /** + * MySQL + */ + MY_SQL( DbType.MYSQL, "MySQL", "FIND_IN_SET('#{value}', #{column}) <> 0"), + + /** + * Oracle + */ + ORACLE(DbType.ORACLE, "Oracle", "FIND_IN_SET('#{value}', #{column}) <> 0"), + + /** + * PostgreSQL + * + * 华为 openGauss 使用 ProductName 与 PostgreSQL 相同 + */ + POSTGRE_SQL(DbType.POSTGRE_SQL,"PostgreSQL", "POSITION('#{value}' IN #{column}) <> 0"), + + /** + * SQL Server + */ + SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"), + + /** + * 达梦 + */ + DM(DbType.DM, "DM DBMS", "FIND_IN_SET('#{value}', #{column}) <> 0"), + + /** + * 人大金仓 + */ + KINGBASE_ES(DbType.KINGBASE_ES, "KingbaseES", "POSITION('#{value}' IN #{column}) <> 0"), + ; + + public static final Map<String, DbTypeEnum> MAP_BY_NAME = Arrays.stream(values()) + .collect(Collectors.toMap(DbTypeEnum::getProductName, Function.identity())); + + public static final Map<DbType, DbTypeEnum> MAP_BY_MP = Arrays.stream(values()) + .collect(Collectors.toMap(DbTypeEnum::getMpDbType, Function.identity())); + + /** + * MyBatis Plus 类型 + */ + private final DbType mpDbType; + /** + * 数据库产品名 + */ + private final String productName; + /** + * SQL FIND_IN_SET 模板 + */ + private final String findInSetTemplate; + + public static DbType find(String databaseProductName) { + if (StrUtil.isBlank(databaseProductName)) { + return null; + } + return MAP_BY_NAME.get(databaseProductName).getMpDbType(); + } + + public static String getFindInSetTemplate(DbType dbType) { + return Optional.of(MAP_BY_MP.get(dbType).getFindInSetTemplate()) + .orElseThrow(() -> new IllegalArgumentException("FIND_IN_SET not supported")); + } +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/SqlConstants.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/SqlConstants.java new file mode 100644 index 0000000..5ba0046 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/enums/SqlConstants.java @@ -0,0 +1,22 @@ +package com.iailab.framework.mybatis.core.enums; + +import com.baomidou.mybatisplus.annotation.DbType; + +/** + * SQL相关常量类 + * + * @author iailab + */ +public class SqlConstants { + + /** + * 数据库的类型 + */ + // TODO 此处使用nacos配置文件的话,先初始化nacos配置才初始化dbType,导致读不到值。暂时先固定Mysql + public static DbType DB_TYPE = DbType.MYSQL; + + public static void init(DbType dbType) { + DB_TYPE = dbType; + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/DefaultDBFieldHandler.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/DefaultDBFieldHandler.java new file mode 100644 index 0000000..f047f87 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/DefaultDBFieldHandler.java @@ -0,0 +1,62 @@ +package com.iailab.framework.mybatis.core.handler; + +import com.iailab.framework.mybatis.core.dataobject.BaseDO; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import org.apache.ibatis.reflection.MetaObject; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 通用参数填充实现类 + * + * 如果没有显式的对通用参数进行赋值,这里会对通用参数进行填充、赋值 + * + * @author hexiaowu + */ +public class DefaultDBFieldHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + if (Objects.nonNull(metaObject) && metaObject.getOriginalObject() instanceof BaseDO) { + BaseDO baseDO = (BaseDO) metaObject.getOriginalObject(); + + LocalDateTime current = LocalDateTime.now(); + // 创建时间为空,则以当前时间为插入时间 + if (Objects.isNull(baseDO.getCreateTime())) { + baseDO.setCreateTime(current); + } + // 更新时间为空,则以当前时间为更新时间 + if (Objects.isNull(baseDO.getUpdateTime())) { + baseDO.setUpdateTime(current); + } + + Long userId = WebFrameworkUtils.getLoginUserId(); + // 当前登录用户不为空,创建人为空,则当前登录用户为创建人 + if (Objects.nonNull(userId) && Objects.isNull(baseDO.getCreator())) { + baseDO.setCreator(userId.toString()); + } + // 当前登录用户不为空,更新人为空,则当前登录用户为更新人 + if (Objects.nonNull(userId) && Objects.isNull(baseDO.getUpdater())) { + baseDO.setUpdater(userId.toString()); + } + } + } + + @Override + public void updateFill(MetaObject metaObject) { + // 更新时间为空,则以当前时间为更新时间 + Object modifyTime = getFieldValByName("updateTime", metaObject); + if (Objects.isNull(modifyTime)) { + setFieldValByName("updateTime", LocalDateTime.now(), metaObject); + } + + // 当前登录用户不为空,更新人为空,则当前登录用户为更新人 + Object modifier = getFieldValByName("updater", metaObject); + Long userId = WebFrameworkUtils.getLoginUserId(); + if (Objects.nonNull(userId) && Objects.isNull(modifier)) { + setFieldValByName("updater", userId.toString(), metaObject); + } + } +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/MybatisHandler.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/MybatisHandler.java new file mode 100644 index 0000000..c7653fe --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/handler/MybatisHandler.java @@ -0,0 +1,45 @@ +package com.iailab.framework.mybatis.core.handler; + +import com.alibaba.fastjson.JSON; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public class MybatisHandler extends BaseTypeHandler<Object> { + + @Override + public void setNonNullParameter(PreparedStatement preparedStatement, int i, Object o, JdbcType jdbcType) throws SQLException { + preparedStatement.setString(i, JSON.toJSONString(o)); + } + + @Override + public Object getNullableResult(ResultSet resultSet, String s) throws SQLException { + String sqlJson = resultSet.getString(s); + if (null != sqlJson) { + return JSON.parse(sqlJson); + } + return null; + } + + @Override + public Object getNullableResult(ResultSet resultSet, int i) throws SQLException { + String sqlJson = resultSet.getString(i); + if (null != sqlJson) { + return JSON.parse(sqlJson); + } + return null; + } + + @Override + public Object getNullableResult(CallableStatement callableStatement, int i) throws SQLException { + String sqlJson = callableStatement.getString(i); + if (null != sqlJson) { + return JSON.parse(sqlJson); + } + return null; + } +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/mapper/BaseMapperX.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/mapper/BaseMapperX.java new file mode 100644 index 0000000..ccf2c77 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/mapper/BaseMapperX.java @@ -0,0 +1,225 @@ +package com.iailab.framework.mybatis.core.mapper; + +import cn.hutool.core.collection.CollUtil; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.iailab.framework.common.pojo.PageParam; +import com.iailab.framework.common.pojo.PageResult; +import com.iailab.framework.common.pojo.SortablePageParam; +import com.iailab.framework.common.pojo.SortingField; +import com.iailab.framework.mybatis.core.enums.SqlConstants; +import com.iailab.framework.mybatis.core.util.MyBatisUtils; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import com.baomidou.mybatisplus.extension.toolkit.Db; +import com.github.yulichang.base.MPJBaseMapper; +import com.github.yulichang.interfaces.MPJBaseJoin; +import com.github.yulichang.wrapper.MPJLambdaWrapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** + * 在 MyBatis Plus 的 BaseMapper 的基础上拓展,提供更多的能力 + * + * 1. {@link BaseMapper} 为 MyBatis Plus 的基础接口,提供基础的 CRUD 能力 + * 2. {@link MPJBaseMapper} 为 MyBatis Plus Join 的基础接口,提供连表 Join 能力 + */ +public interface BaseMapperX<T> extends MPJBaseMapper<T> { + + /** + * 获取分页对象 + * @param params 分页查询参数 + */ + default IPage<T> getPage(PageParam params) { + //分页参数 + long curPage = 1; + long limit = 10; + + if(params.getPageNo() != null){ + curPage = params.getPageNo(); + } + if(params.getPageSize() != null){ + limit = params.getPageSize(); + } + + //分页对象 + return new Page<>(curPage, limit); + } + + default PageResult<T> selectPage(SortablePageParam pageParam, @Param("ew") Wrapper<T> queryWrapper) { + return selectPage(pageParam, pageParam.getSortingFields(), queryWrapper); + } + + default PageResult<T> selectPage(PageParam pageParam, @Param("ew") Wrapper<T> queryWrapper) { + return selectPage(pageParam, null, queryWrapper); + } + + default PageResult<T> selectPage(PageParam pageParam, Collection<SortingField> sortingFields, @Param("ew") Wrapper<T> queryWrapper) { + // 特殊:不分页,直接查询全部 + if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) { + List<T> list = selectList(queryWrapper); + return new PageResult<>(list, (long) list.size()); + } + + // MyBatis Plus 查询 + IPage<T> mpPage = MyBatisUtils.buildPage(pageParam, sortingFields); + selectPage(mpPage, queryWrapper); + // 转换返回 + return new PageResult<>(mpPage.getRecords(), mpPage.getTotal()); + } + + default <D> PageResult<D> selectJoinPage(PageParam pageParam, Class<D> clazz, MPJLambdaWrapper<T> lambdaWrapper) { + // 特殊:不分页,直接查询全部 + if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) { + List<D> list = selectJoinList(clazz, lambdaWrapper); + return new PageResult<>(list, (long) list.size()); + } + + // MyBatis Plus Join 查询 + IPage<D> mpPage = MyBatisUtils.buildPage(pageParam); + mpPage = selectJoinPage(mpPage, clazz, lambdaWrapper); + // 转换返回 + return new PageResult<>(mpPage.getRecords(), mpPage.getTotal()); + } + + default <DTO> PageResult<DTO> selectJoinPage(PageParam pageParam, Class<DTO> resultTypeClass, MPJBaseJoin<T> joinQueryWrapper) { + IPage<DTO> mpPage = MyBatisUtils.buildPage(pageParam); + selectJoinPage(mpPage, resultTypeClass, joinQueryWrapper); + // 转换返回 + return new PageResult<>(mpPage.getRecords(), mpPage.getTotal()); + } + + default T selectOne(String field, Object value) { + return selectOne(new QueryWrapper<T>().eq(field, value)); + } + + default T selectOne(SFunction<T, ?> field, Object value) { + return selectOne(new LambdaQueryWrapper<T>().eq(field, value)); + } + + default T selectOne(String field1, Object value1, String field2, Object value2) { + return selectOne(new QueryWrapper<T>().eq(field1, value1).eq(field2, value2)); + } + + default T selectOne(SFunction<T, ?> field1, Object value1, SFunction<T, ?> field2, Object value2) { + return selectOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2)); + } + + default T selectOne(SFunction<T, ?> field1, Object value1, SFunction<T, ?> field2, Object value2, + SFunction<T, ?> field3, Object value3) { + return selectOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2) + .eq(field3, value3)); + } + + default Long selectCount() { + return selectCount(new QueryWrapper<>()); + } + + default Long selectCount(String field, Object value) { + return selectCount(new QueryWrapper<T>().eq(field, value)); + } + + default Long selectCount(SFunction<T, ?> field, Object value) { + return selectCount(new LambdaQueryWrapper<T>().eq(field, value)); + } + + default List<T> selectList() { + return selectList(new QueryWrapper<>()); + } + + default List<T> selectList(String field, Object value) { + return selectList(new QueryWrapper<T>().eq(field, value)); + } + + default List<T> selectList(SFunction<T, ?> field, Object value) { + return selectList(new LambdaQueryWrapper<T>().eq(field, value)); + } + + default List<T> selectList(String field, Collection<?> values) { + if (CollUtil.isEmpty(values)) { + return CollUtil.newArrayList(); + } + return selectList(new QueryWrapper<T>().in(field, values)); + } + + default List<T> selectList(SFunction<T, ?> field, Collection<?> values) { + if (CollUtil.isEmpty(values)) { + return CollUtil.newArrayList(); + } + return selectList(new LambdaQueryWrapper<T>().in(field, values)); + } + + @Deprecated + default List<T> selectList(SFunction<T, ?> leField, SFunction<T, ?> geField, Object value) { + return selectList(new LambdaQueryWrapper<T>().le(leField, value).ge(geField, value)); + } + + default List<T> selectList(SFunction<T, ?> field1, Object value1, SFunction<T, ?> field2, Object value2) { + return selectList(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2)); + } + + /** + * 批量插入,适合大量数据插入 + * + * @param entities 实体们 + */ + default Boolean insertBatch(Collection<T> entities) { + // 特殊:SQL Server 批量插入后,获取 id 会报错,因此通过循环处理 + if (Objects.equals(SqlConstants.DB_TYPE, DbType.SQL_SERVER)) { + entities.forEach(this::insert); + return CollUtil.isNotEmpty(entities); + } + return Db.saveBatch(entities); + } + + /** + * 批量插入,适合大量数据插入 + * + * @param entities 实体们 + * @param size 插入数量 Db.saveBatch 默认为 1000 + */ + default Boolean insertBatch(Collection<T> entities, int size) { + // 特殊:SQL Server 批量插入后,获取 id 会报错,因此通过循环处理 + if (Objects.equals(SqlConstants.DB_TYPE, DbType.SQL_SERVER)) { + entities.forEach(this::insert); + return CollUtil.isNotEmpty(entities); + } + return Db.saveBatch(entities, size); + } + + default int updateBatch(T update) { + return update(update, new QueryWrapper<>()); + } + + default Boolean updateBatch(Collection<T> entities) { + return Db.updateBatchById(entities); + } + + default Boolean updateBatch(Collection<T> entities, int size) { + return Db.updateBatchById(entities, size); + } + + default boolean insertOrUpdate(T entity) { + return Db.saveOrUpdate(entity); + } + + default Boolean insertOrUpdateBatch(Collection<T> collection) { + return Db.saveOrUpdateBatch(collection); + } + + default int delete(String field, String value) { + return delete(new QueryWrapper<T>().eq(field, value)); + } + + default int delete(SFunction<T, ?> field, Object value) { + return delete(new LambdaQueryWrapper<T>().eq(field, value)); + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/LambdaQueryWrapperX.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/LambdaQueryWrapperX.java new file mode 100644 index 0000000..cfca5c9 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/LambdaQueryWrapperX.java @@ -0,0 +1,135 @@ +package com.iailab.framework.mybatis.core.query; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import com.iailab.framework.common.util.collection.ArrayUtils; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import org.springframework.util.StringUtils; + +import java.util.Collection; + +/** + * 拓展 MyBatis Plus QueryWrapper 类,主要增加如下功能: + * <p> + * 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。 + * + * @param <T> 数据类型 + */ +public class LambdaQueryWrapperX<T> extends LambdaQueryWrapper<T> { + + public LambdaQueryWrapperX<T> likeIfPresent(SFunction<T, ?> column, String val) { + if (StringUtils.hasText(val)) { + return (LambdaQueryWrapperX<T>) super.like(column, val); + } + return this; + } + + public LambdaQueryWrapperX<T> inIfPresent(SFunction<T, ?> column, Collection<?> values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (LambdaQueryWrapperX<T>) super.in(column, values); + } + return this; + } + + public LambdaQueryWrapperX<T> inIfPresent(SFunction<T, ?> column, Object... values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (LambdaQueryWrapperX<T>) super.in(column, values); + } + return this; + } + + public LambdaQueryWrapperX<T> eqIfPresent(SFunction<T, ?> column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (LambdaQueryWrapperX<T>) super.eq(column, val); + } + return this; + } + + public LambdaQueryWrapperX<T> neIfPresent(SFunction<T, ?> column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (LambdaQueryWrapperX<T>) super.ne(column, val); + } + return this; + } + + public LambdaQueryWrapperX<T> gtIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX<T>) super.gt(column, val); + } + return this; + } + + public LambdaQueryWrapperX<T> geIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX<T>) super.ge(column, val); + } + return this; + } + + public LambdaQueryWrapperX<T> ltIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX<T>) super.lt(column, val); + } + return this; + } + + public LambdaQueryWrapperX<T> leIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (LambdaQueryWrapperX<T>) super.le(column, val); + } + return this; + } + + public LambdaQueryWrapperX<T> betweenIfPresent(SFunction<T, ?> column, Object val1, Object val2) { + if (val1 != null && val2 != null) { + return (LambdaQueryWrapperX<T>) super.between(column, val1, val2); + } + if (val1 != null) { + return (LambdaQueryWrapperX<T>) ge(column, val1); + } + if (val2 != null) { + return (LambdaQueryWrapperX<T>) le(column, val2); + } + return this; + } + + public LambdaQueryWrapperX<T> betweenIfPresent(SFunction<T, ?> column, Object[] values) { + Object val1 = ArrayUtils.get(values, 0); + Object val2 = ArrayUtils.get(values, 1); + return betweenIfPresent(column, val1, val2); + } + + // ========== 重写父类方法,方便链式调用 ========== + + @Override + public LambdaQueryWrapperX<T> eq(boolean condition, SFunction<T, ?> column, Object val) { + super.eq(condition, column, val); + return this; + } + + @Override + public LambdaQueryWrapperX<T> eq(SFunction<T, ?> column, Object val) { + super.eq(column, val); + return this; + } + + @Override + public LambdaQueryWrapperX<T> orderByDesc(SFunction<T, ?> column) { + super.orderByDesc(true, column); + return this; + } + + @Override + public LambdaQueryWrapperX<T> last(String lastSql) { + super.last(lastSql); + return this; + } + + @Override + public LambdaQueryWrapperX<T> in(SFunction<T, ?> column, Collection<?> coll) { + super.in(column, coll); + return this; + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/MPJLambdaWrapperX.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/MPJLambdaWrapperX.java new file mode 100644 index 0000000..fa8ebc7 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/MPJLambdaWrapperX.java @@ -0,0 +1,313 @@ +package com.iailab.framework.mybatis.core.query; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import com.iailab.framework.common.util.collection.ArrayUtils; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; +import com.github.yulichang.toolkit.MPJWrappers; +import com.github.yulichang.wrapper.MPJLambdaWrapper; +import org.springframework.util.StringUtils; + +import java.util.Collection; +import java.util.function.Consumer; + +/** + * 拓展 MyBatis Plus Join QueryWrapper 类,主要增加如下功能: + * <p> + * 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。 + * + * @param <T> 数据类型 + */ +public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> { + + public MPJLambdaWrapperX<T> likeIfPresent(SFunction<T, ?> column, String val) { + MPJWrappers.lambdaJoin().like(column, val); + if (StringUtils.hasText(val)) { + return (MPJLambdaWrapperX<T>) super.like(column, val); + } + return this; + } + + public MPJLambdaWrapperX<T> inIfPresent(SFunction<T, ?> column, Collection<?> values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (MPJLambdaWrapperX<T>) super.in(column, values); + } + return this; + } + + public MPJLambdaWrapperX<T> inIfPresent(SFunction<T, ?> column, Object... values) { + if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) { + return (MPJLambdaWrapperX<T>) super.in(column, values); + } + return this; + } + + public MPJLambdaWrapperX<T> eqIfPresent(SFunction<T, ?> column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (MPJLambdaWrapperX<T>) super.eq(column, val); + } + return this; + } + + public MPJLambdaWrapperX<T> neIfPresent(SFunction<T, ?> column, Object val) { + if (ObjectUtil.isNotEmpty(val)) { + return (MPJLambdaWrapperX<T>) super.ne(column, val); + } + return this; + } + + public MPJLambdaWrapperX<T> gtIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX<T>) super.gt(column, val); + } + return this; + } + + public MPJLambdaWrapperX<T> geIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX<T>) super.ge(column, val); + } + return this; + } + + public MPJLambdaWrapperX<T> ltIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX<T>) super.lt(column, val); + } + return this; + } + + public MPJLambdaWrapperX<T> leIfPresent(SFunction<T, ?> column, Object val) { + if (val != null) { + return (MPJLambdaWrapperX<T>) super.le(column, val); + } + return this; + } + + public MPJLambdaWrapperX<T> betweenIfPresent(SFunction<T, ?> column, Object val1, Object val2) { + if (val1 != null && val2 != null) { + return (MPJLambdaWrapperX<T>) super.between(column, val1, val2); + } + if (val1 != null) { + return (MPJLambdaWrapperX<T>) ge(column, val1); + } + if (val2 != null) { + return (MPJLambdaWrapperX<T>) le(column, val2); + } + return this; + } + + public MPJLambdaWrapperX<T> betweenIfPresent(SFunction<T, ?> column, Object[] values) { + Object val1 = ArrayUtils.get(values, 0); + Object val2 = ArrayUtils.get(values, 1); + return betweenIfPresent(column, val1, val2); + } + + // ========== 重写父类方法,方便链式调用 ========== + + @Override + public <X> MPJLambdaWrapperX<T> eq(boolean condition, SFunction<X, ?> column, Object val) { + super.eq(condition, column, val); + return this; + } + + @Override + public <X> MPJLambdaWrapperX<T> eq(SFunction<X, ?> column, Object val) { + super.eq(column, val); + return this; + } + + @Override + public <X> MPJLambdaWrapperX<T> orderByDesc(SFunction<X, ?> column) { + //noinspection unchecked + super.orderByDesc(true, column); + return this; + } + + @Override + public MPJLambdaWrapperX<T> last(String lastSql) { + super.last(lastSql); + return this; + } + + @Override + public <X> MPJLambdaWrapperX<T> in(SFunction<X, ?> column, Collection<?> coll) { + super.in(column, coll); + return this; + } + + @Override + public MPJLambdaWrapperX<T> selectAll(Class<?> clazz) { + super.selectAll(clazz); + return this; + } + + @Override + public MPJLambdaWrapperX<T> selectAll(Class<?> clazz, String prefix) { + super.selectAll(clazz, prefix); + return this; + } + + @Override + public <S> MPJLambdaWrapperX<T> selectAs(SFunction<S, ?> column, String alias) { + super.selectAs(column, alias); + return this; + } + + @Override + public <E> MPJLambdaWrapperX<T> selectAs(String column, SFunction<E, ?> alias) { + super.selectAs(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectAs(SFunction<S, ?> column, SFunction<X, ?> alias) { + super.selectAs(column, alias); + return this; + } + + @Override + public <E, X> MPJLambdaWrapperX<T> selectAs(String index, SFunction<E, ?> column, SFunction<X, ?> alias) { + super.selectAs(index, column, alias); + return this; + } + + @Override + public <E> MPJLambdaWrapperX<T> selectAsClass(Class<E> source, Class<?> tag) { + super.selectAsClass(source, tag); + return this; + } + + @Override + public <E, F> MPJLambdaWrapperX<T> selectSub(Class<E> clazz, Consumer<MPJLambdaWrapper<E>> consumer, SFunction<F, ?> alias) { + super.selectSub(clazz, consumer, alias); + return this; + } + + @Override + public <E, F> MPJLambdaWrapperX<T> selectSub(Class<E> clazz, String st, Consumer<MPJLambdaWrapper<E>> consumer, SFunction<F, ?> alias) { + super.selectSub(clazz, st, consumer, alias); + return this; + } + + @Override + public <S> MPJLambdaWrapperX<T> selectCount(SFunction<S, ?> column) { + super.selectCount(column); + return this; + } + + @Override + public MPJLambdaWrapperX<T> selectCount(Object column, String alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public <X> MPJLambdaWrapperX<T> selectCount(Object column, SFunction<X, ?> alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectCount(SFunction<S, ?> column, String alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectCount(SFunction<S, ?> column, SFunction<X, ?> alias) { + super.selectCount(column, alias); + return this; + } + + @Override + public <S> MPJLambdaWrapperX<T> selectSum(SFunction<S, ?> column) { + super.selectSum(column); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectSum(SFunction<S, ?> column, String alias) { + super.selectSum(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectSum(SFunction<S, ?> column, SFunction<X, ?> alias) { + super.selectSum(column, alias); + return this; + } + + @Override + public <S> MPJLambdaWrapperX<T> selectMax(SFunction<S, ?> column) { + super.selectMax(column); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectMax(SFunction<S, ?> column, String alias) { + super.selectMax(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectMax(SFunction<S, ?> column, SFunction<X, ?> alias) { + super.selectMax(column, alias); + return this; + } + + @Override + public <S> MPJLambdaWrapperX<T> selectMin(SFunction<S, ?> column) { + super.selectMin(column); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectMin(SFunction<S, ?> column, String alias) { + super.selectMin(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectMin(SFunction<S, ?> column, SFunction<X, ?> alias) { + super.selectMin(column, alias); + return this; + } + + @Override + public <S> MPJLambdaWrapperX<T> selectAvg(SFunction<S, ?> column) { + super.selectAvg(column); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectAvg(SFunction<S, ?> column, String alias) { + super.selectAvg(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectAvg(SFunction<S, ?> column, SFunction<X, ?> alias) { + super.selectAvg(column, alias); + return this; + } + + @Override + public <S> MPJLambdaWrapperX<T> selectLen(SFunction<S, ?> column) { + super.selectLen(column); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectLen(SFunction<S, ?> column, String alias) { + super.selectLen(column, alias); + return this; + } + + @Override + public <S, X> MPJLambdaWrapperX<T> selectLen(SFunction<S, ?> column, SFunction<X, ?> alias) { + super.selectLen(column, alias); + return this; + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/QueryWrapperX.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/QueryWrapperX.java new file mode 100644 index 0000000..bf79bf5 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/query/QueryWrapperX.java @@ -0,0 +1,166 @@ +package com.iailab.framework.mybatis.core.query; + +import cn.hutool.core.lang.Assert; +import com.iailab.framework.mybatis.core.enums.SqlConstants; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.ArrayUtils; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; +import org.springframework.util.StringUtils; + +import java.util.Collection; + +/** + * 拓展 MyBatis Plus QueryWrapper 类,主要增加如下功能: + * + * 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。 + * + * @param <T> 数据类型 + */ +public class QueryWrapperX<T> extends QueryWrapper<T> { + + public QueryWrapperX<T> likeIfPresent(String column, String val) { + if (StringUtils.hasText(val)) { + return (QueryWrapperX<T>) super.like(column, val); + } + return this; + } + + public QueryWrapperX<T> inIfPresent(String column, Collection<?> values) { + if (!CollectionUtils.isEmpty(values)) { + return (QueryWrapperX<T>) super.in(column, values); + } + return this; + } + + public QueryWrapperX<T> inIfPresent(String column, Object... values) { + if (!ArrayUtils.isEmpty(values)) { + return (QueryWrapperX<T>) super.in(column, values); + } + return this; + } + + public QueryWrapperX<T> eqIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX<T>) super.eq(column, val); + } + return this; + } + + public QueryWrapperX<T> neIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX<T>) super.ne(column, val); + } + return this; + } + + public QueryWrapperX<T> gtIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX<T>) super.gt(column, val); + } + return this; + } + + public QueryWrapperX<T> geIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX<T>) super.ge(column, val); + } + return this; + } + + public QueryWrapperX<T> ltIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX<T>) super.lt(column, val); + } + return this; + } + + public QueryWrapperX<T> leIfPresent(String column, Object val) { + if (val != null) { + return (QueryWrapperX<T>) super.le(column, val); + } + return this; + } + + public QueryWrapperX<T> betweenIfPresent(String column, Object val1, Object val2) { + if (val1 != null && val2 != null) { + return (QueryWrapperX<T>) super.between(column, val1, val2); + } + if (val1 != null) { + return (QueryWrapperX<T>) ge(column, val1); + } + if (val2 != null) { + return (QueryWrapperX<T>) le(column, val2); + } + return this; + } + + public QueryWrapperX<T> betweenIfPresent(String column, Object[] values) { + if (values!= null && values.length != 0 && values[0] != null && values[1] != null) { + return (QueryWrapperX<T>) super.between(column, values[0], values[1]); + } + if (values!= null && values.length != 0 && values[0] != null) { + return (QueryWrapperX<T>) ge(column, values[0]); + } + if (values!= null && values.length != 0 && values[1] != null) { + return (QueryWrapperX<T>) le(column, values[1]); + } + return this; + } + + // ========== 重写父类方法,方便链式调用 ========== + + @Override + public QueryWrapperX<T> eq(boolean condition, String column, Object val) { + super.eq(condition, column, val); + return this; + } + + @Override + public QueryWrapperX<T> eq(String column, Object val) { + super.eq(column, val); + return this; + } + + @Override + public QueryWrapperX<T> orderByDesc(String column) { + super.orderByDesc(true, column); + return this; + } + + @Override + public QueryWrapperX<T> last(String lastSql) { + super.last(lastSql); + return this; + } + + @Override + public QueryWrapperX<T> in(String column, Collection<?> coll) { + super.in(column, coll); + return this; + } + + /** + * 设置只返回最后一条 + * + * TODO iailab:不是完美解,需要在思考下。如果使用多数据源,并且数据源是多种类型时,可能会存在问题:实现之返回一条的语法不同 + * + * @return this + */ + public QueryWrapperX<T> limitN(int n) { + Assert.notNull(SqlConstants.DB_TYPE, "获取不到数据库的类型"); + switch (SqlConstants.DB_TYPE) { + case ORACLE: + case ORACLE_12C: + super.le("ROWNUM", n); + break; + case SQL_SERVER: + case SQL_SERVER2005: + super.select("TOP " + n + " *"); // 由于 SQL Server 是通过 SELECT TOP 1 实现限制一条,所以只好使用 * 查询剩余字段 + break; + default: + super.last("LIMIT " + n); + } + return this; + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/EncryptTypeHandler.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/EncryptTypeHandler.java new file mode 100644 index 0000000..57f92e2 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/EncryptTypeHandler.java @@ -0,0 +1,75 @@ +package com.iailab.framework.mybatis.core.type; + +import cn.hutool.core.lang.Assert; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.symmetric.AES; +import cn.hutool.extra.spring.SpringUtil; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * 字段字段的 TypeHandler 实现类,基于 {@link cn.hutool.crypto.symmetric.AES} 实现 + * 可通过 jasypt.encryptor.password 配置项,设置密钥 + * + * @author iailab + */ +public class EncryptTypeHandler extends BaseTypeHandler<String> { + + private static final String ENCRYPTOR_PROPERTY_NAME = "mybatis-plus.encryptor.password"; + + private static AES aes; + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { + ps.setString(i, encrypt(parameter)); + } + + @Override + public String getNullableResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return decrypt(value); + } + + @Override + public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return decrypt(value); + } + + @Override + public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return decrypt(value); + } + + private static String decrypt(String value) { + if (value == null) { + return null; + } + return getEncryptor().decryptStr(value); + } + + public static String encrypt(String rawValue) { + if (rawValue == null) { + return null; + } + return getEncryptor().encryptBase64(rawValue); + } + + private static AES getEncryptor() { + if (aes != null) { + return aes; + } + // 构建 AES + String password = SpringUtil.getProperty(ENCRYPTOR_PROPERTY_NAME); + Assert.notEmpty(password, "配置项({}) 不能为空", ENCRYPTOR_PROPERTY_NAME); + aes = SecureUtil.aes(password.getBytes()); + return aes; + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/IntegerListTypeHandler.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/IntegerListTypeHandler.java new file mode 100644 index 0000000..11041bc --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/IntegerListTypeHandler.java @@ -0,0 +1,56 @@ +package com.iailab.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.common.util.string.StrUtils; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.apache.ibatis.type.TypeHandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +/** + * List<Integer> 的类型转换器实现类,对应数据库的 varchar 类型 + * + * @author jason + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(List.class) +public class IntegerListTypeHandler implements TypeHandler<List<Integer>> { + + private static final String COMMA = ","; + + @Override + public void setParameter(PreparedStatement ps, int i, List<Integer> strings, JdbcType jdbcType) throws SQLException { + ps.setString(i, CollUtil.join(strings, COMMA)); + } + + @Override + public List<Integer> getResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return getResult(value); + } + + @Override + public List<Integer> getResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return getResult(value); + } + + @Override + public List<Integer> getResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return getResult(value); + } + + private List<Integer> getResult(String value) { + if (value == null) { + return null; + } + return StrUtils.splitToInteger(value, COMMA); + } +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/JsonLongSetTypeHandler.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/JsonLongSetTypeHandler.java new file mode 100644 index 0000000..cce6ac8 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/JsonLongSetTypeHandler.java @@ -0,0 +1,31 @@ +//package com.iailab.framework.mybatis.core.type; +// +//import com.iailab.framework.common.util.json.JsonUtils; +//import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler; +//import com.fasterxml.jackson.core.type.TypeReference; +// +//import java.util.Set; +// +///** +// * 参考 {@link com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler} 实现 +// * 在我们将字符串反序列化为 Set 并且泛型为 Long 时,如果每个元素的数值太小,会被处理成 Integer 类型,导致可能存在隐性的 BUG。 +// * +// * 例如说哦,SysUserDO 的 postIds 属性 +// * +// * @author iailab +// */ +//public class JsonLongSetTypeHandler extends AbstractJsonTypeHandler<Object> { +// +// private static final TypeReference<Set<Long>> TYPE_REFERENCE = new TypeReference<Set<Long>>(){}; +// +// @Override +// protected Object parse(String json) { +// return JsonUtils.parseObject(json, TYPE_REFERENCE); +// } +// +// @Override +// protected String toJson(Object obj) { +// return JsonUtils.toJsonString(obj); +// } +// +//} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/LongListTypeHandler.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/LongListTypeHandler.java new file mode 100644 index 0000000..4bbd179 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/LongListTypeHandler.java @@ -0,0 +1,57 @@ +package com.iailab.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.common.util.string.StrUtils; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.apache.ibatis.type.TypeHandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +/** + * List<Long> 的类型转换器实现类,对应数据库的 varchar 类型 + * + * @author iailab + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(List.class) +public class LongListTypeHandler implements TypeHandler<List<Long>> { + + private static final String COMMA = ","; + + @Override + public void setParameter(PreparedStatement ps, int i, List<Long> strings, JdbcType jdbcType) throws SQLException { + // 设置占位符 + ps.setString(i, CollUtil.join(strings, COMMA)); + } + + @Override + public List<Long> getResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return getResult(value); + } + + @Override + public List<Long> getResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return getResult(value); + } + + @Override + public List<Long> getResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return getResult(value); + } + + private List<Long> getResult(String value) { + if (value == null) { + return null; + } + return StrUtils.splitToLong(value, COMMA); + } +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/StringListTypeHandler.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/StringListTypeHandler.java new file mode 100644 index 0000000..024ffc0 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/type/StringListTypeHandler.java @@ -0,0 +1,58 @@ +package com.iailab.framework.mybatis.core.type; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.apache.ibatis.type.TypeHandler; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +/** + * List<String> 的类型转换器实现类,对应数据库的 varchar 类型 + * + * @author 永不言败 + * @since 2022 3/23 12:50:15 + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +@MappedTypes(List.class) +public class StringListTypeHandler implements TypeHandler<List<String>> { + + private static final String COMMA = ","; + + @Override + public void setParameter(PreparedStatement ps, int i, List<String> strings, JdbcType jdbcType) throws SQLException { + // 设置占位符 + ps.setString(i, CollUtil.join(strings, COMMA)); + } + + @Override + public List<String> getResult(ResultSet rs, String columnName) throws SQLException { + String value = rs.getString(columnName); + return getResult(value); + } + + @Override + public List<String> getResult(ResultSet rs, int columnIndex) throws SQLException { + String value = rs.getString(columnIndex); + return getResult(value); + } + + @Override + public List<String> getResult(CallableStatement cs, int columnIndex) throws SQLException { + String value = cs.getString(columnIndex); + return getResult(value); + } + + private List<String> getResult(String value) { + if (value == null) { + return null; + } + return StrUtil.splitTrim(value, COMMA); + } +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/JdbcUtils.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/JdbcUtils.java new file mode 100644 index 0000000..0275079 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/JdbcUtils.java @@ -0,0 +1,61 @@ +package com.iailab.framework.mybatis.core.util; + +import com.baomidou.dynamic.datasource.DynamicRoutingDataSource; +import com.baomidou.mybatisplus.annotation.DbType; +import com.iailab.framework.common.util.spring.SpringUtils; +import com.iailab.framework.mybatis.core.enums.DbTypeEnum; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * JDBC 工具类 + * + * @author iailab + */ +public class JdbcUtils { + + /** + * 判断连接是否正确 + * + * @param url 数据源连接 + * @param username 账号 + * @param password 密码 + * @return 是否正确 + */ + public static boolean isConnectionOK(String url, String username, String password) { + try (Connection ignored = DriverManager.getConnection(url, username, password)) { + return true; + } catch (Exception ex) { + return false; + } + } + + /** + * 获得 URL 对应的 DB 类型 + * + * @param url URL + * @return DB 类型 + */ + public static DbType getDbType(String url) { + return com.baomidou.mybatisplus.extension.toolkit.JdbcUtils.getDbType(url); + } + + /** + * 通过当前数据库连接获得对应的 DB 类型 + * + * @return DB 类型 + */ + public static DbType getDbType() { + DynamicRoutingDataSource dynamicRoutingDataSource = SpringUtils.getBean(DynamicRoutingDataSource.class); + DataSource dataSource = dynamicRoutingDataSource.determineDataSource(); + try (Connection conn = dataSource.getConnection()) { + return DbTypeEnum.find(conn.getMetaData().getDatabaseProductName()); + } catch (SQLException e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/MyBatisUtils.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/MyBatisUtils.java new file mode 100644 index 0000000..a8d8411 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/core/util/MyBatisUtils.java @@ -0,0 +1,106 @@ +package com.iailab.framework.mybatis.core.util; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +import com.baomidou.mybatisplus.core.toolkit.StringPool; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.iailab.framework.common.pojo.PageParam; +import com.iailab.framework.common.pojo.SortingField; +import com.iailab.framework.mybatis.core.enums.DbTypeEnum; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * MyBatis 工具类 + */ +public class MyBatisUtils { + + private static final String MYSQL_ESCAPE_CHARACTER = "`"; + + public static <T> Page<T> buildPage(PageParam pageParam) { + return buildPage(pageParam, null); + } + + public static <T> Page<T> buildPage(PageParam pageParam, Collection<SortingField> sortingFields) { + // 页码 + 数量 + Page<T> page = new Page<>(pageParam.getPageNo(), pageParam.getPageSize()); + // 排序字段 + if (!CollectionUtil.isEmpty(sortingFields)) { + page.addOrder(sortingFields.stream().map(sortingField -> SortingField.ORDER_ASC.equals(sortingField.getOrder()) ? + OrderItem.asc(sortingField.getField()) : OrderItem.desc(sortingField.getField())) + .collect(Collectors.toList())); + } + return page; + } + + /** + * 将拦截器添加到链中 + * 由于 MybatisPlusInterceptor 不支持添加拦截器,所以只能全量设置 + * + * @param interceptor 链 + * @param inner 拦截器 + * @param index 位置 + */ + public static void addInterceptor(MybatisPlusInterceptor interceptor, InnerInterceptor inner, int index) { + List<InnerInterceptor> inners = new ArrayList<>(interceptor.getInterceptors()); + inners.add(index, inner); + interceptor.setInterceptors(inners); + } + + /** + * 获得 Table 对应的表名 + * <p> + * 兼容 MySQL 转义表名 `t_xxx` + * + * @param table 表 + * @return 去除转移字符后的表名 + */ + public static String getTableName(Table table) { + String tableName = table.getName(); + if (tableName.startsWith(MYSQL_ESCAPE_CHARACTER) && tableName.endsWith(MYSQL_ESCAPE_CHARACTER)) { + tableName = tableName.substring(1, tableName.length() - 1); + } + return tableName; + } + + /** + * 构建 Column 对象 + * + * @param tableName 表名 + * @param tableAlias 别名 + * @param column 字段名 + * @return Column 对象 + */ + public static Column buildColumn(String tableName, Alias tableAlias, String column) { + if (tableAlias != null) { + tableName = tableAlias.getName(); + } + return new Column(tableName + StringPool.DOT + column); + } + + /** + * 跨数据库的 find_in_set 实现 + * + * @param column 字段名称 + * @param value 查询值(不带单引号) + * @return sql + */ + public static String findInSet(String column, Object value) { + // 这里不用SqlConstants.DB_TYPE,因为它是使用 primary 数据源的 url 推断出来的类型 + DbType dbType = JdbcUtils.getDbType(); + return DbTypeEnum.getFindInSetTemplate(dbType) + .replace("#{column}", column) + .replace("#{value}", StrUtil.toString(value)); + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataFilterInterceptor.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataFilterInterceptor.java new file mode 100644 index 0000000..8cf8a98 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataFilterInterceptor.java @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.mybatis.interceptor; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; +import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; + +import java.util.Map; + +/** + * 数据过滤 + * + * @author Mark sunlightcs@gmail.com + */ +public class DataFilterInterceptor implements InnerInterceptor { + + @Override + public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { + DataScope scope = getDataScope(parameter); + // 不进行数据过滤 + if(scope == null || StrUtil.isBlank(scope.getSqlFilter())){ + return; + } + + // 拼接新SQL + String buildSql = getSelect(boundSql.getSql(), scope); + + // 重写SQL + PluginUtils.mpBoundSql(boundSql).sql(buildSql); + } + + private DataScope getDataScope(Object parameter){ + if (parameter == null){ + return null; + } + + // 判断参数里是否有DataScope对象 + if (parameter instanceof Map) { + Map<?, ?> parameterMap = (Map<?, ?>) parameter; + for (Map.Entry entry : parameterMap.entrySet()) { + if (entry.getValue() != null && entry.getValue() instanceof DataScope) { + return (DataScope) entry.getValue(); + } + } + } else if (parameter instanceof DataScope) { + return (DataScope) parameter; + } + + return null; + } + + private String getSelect(String buildSql, DataScope scope){ + try { + Select select = (Select) CCJSqlParserUtil.parse(buildSql); + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + + Expression expression = plainSelect.getWhere(); + if(expression == null){ + plainSelect.setWhere(new StringValue(scope.getSqlFilter())); + }else{ + AndExpression andExpression = new AndExpression(expression, new StringValue(scope.getSqlFilter())); + plainSelect.setWhere(andExpression); + } + + return select.toString().replaceAll("'", ""); + }catch (JSQLParserException e){ + return buildSql; + } + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataScope.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataScope.java new file mode 100644 index 0000000..e9f2dcc --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/interceptor/DataScope.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.mybatis.interceptor; + +/** + * 数据范围 + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public class DataScope { + private String sqlFilter; + + public DataScope(String sqlFilter) { + this.sqlFilter = sqlFilter; + } + + public String getSqlFilter() { + return sqlFilter; + } + + public void setSqlFilter(String sqlFilter) { + this.sqlFilter = sqlFilter; + } + + @Override + public String toString() { + return this.sqlFilter; + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/package-info.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/package-info.java new file mode 100644 index 0000000..bf8d7ef --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/mybatis/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 MyBatis Plus 提升使用 MyBatis 的开发效率 + */ +package com.iailab.framework.mybatis; diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/config/IailabTranslateAutoConfiguration.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/config/IailabTranslateAutoConfiguration.java new file mode 100644 index 0000000..55cfff1 --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/config/IailabTranslateAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.iailab.framework.translate.config; + +import com.iailab.framework.translate.core.TranslateUtils; +import com.fhs.trans.service.impl.TransService; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +public class IailabTranslateAutoConfiguration { + + @Bean + @SuppressWarnings({"InstantiationOfUtilityClass", "SpringJavaInjectionPointsAutowiringInspection"}) + public TranslateUtils translateUtils(TransService transService) { + TranslateUtils.init(transService); + return new TranslateUtils(); + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/core/TranslateUtils.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/core/TranslateUtils.java new file mode 100644 index 0000000..94f8b8b --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/core/TranslateUtils.java @@ -0,0 +1,37 @@ +package com.iailab.framework.translate.core; + +import cn.hutool.core.collection.CollUtil; +import com.fhs.core.trans.vo.VO; +import com.fhs.trans.service.impl.TransService; + +import java.util.List; + +/** + * VO 数据翻译 Utils + * + * @author iailab + */ +public class TranslateUtils { + + private static TransService transService; + + public static void init(TransService transService) { + TranslateUtils.transService = transService; + } + + /** + * 数据翻译 + * + * 使用场景:无法使用 @TransMethodResult 注解的场景,只能通过手动触发翻译 + * + * @param data 数据 + * @return 翻译结果 + */ + public static <T extends VO> List<T> translate(List<T> data) { + if (CollUtil.isNotEmpty((data))) { + transService.transBatch(data); + } + return data; + } + +} diff --git a/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/package-info.java b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/package-info.java new file mode 100644 index 0000000..7378e0f --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/java/com/iailab/framework/translate/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 Easy-Trans 提升使用 VO 数据翻译的开发效率 + */ +package com.iailab.framework.translate; diff --git a/iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring.factories b/iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..a0149ea --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ + com.iailab.framework.mybatis.config.IdTypeEnvironmentPostProcessor diff --git a/iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..c3f21ff --- /dev/null +++ b/iailab-framework/iailab-common-mybatis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +com.iailab.framework.datasource.config.IailabDataSourceAutoConfiguration +com.iailab.framework.mybatis.config.IailabMybatisAutoConfiguration +com.iailab.framework.translate.config.IailabTranslateAutoConfiguration \ No newline at end of file diff --git a/iailab-framework/iailab-common-protection/pom.xml b/iailab-framework/iailab-common-protection/pom.xml new file mode 100644 index 0000000..85ca482 --- /dev/null +++ b/iailab-framework/iailab-common-protection/pom.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-protection</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>服务保证,提供分布式锁、幂等、限流、熔断等等功能</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <!-- Web 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-web</artifactId> + <scope>provided</scope> <!-- 设置为 provided,只有限流、幂等使用到 --> + </dependency> + + <!-- DB 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-redis</artifactId> + </dependency> + + <!-- 服务保障相关 --> + <dependency> + <groupId>com.baomidou</groupId> + <artifactId>lock4j-redisson-spring-boot-starter</artifactId> + <optional>true</optional> + </dependency> + + <!-- Test 测试相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-test</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/config/IailabIdempotentConfiguration.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/config/IailabIdempotentConfiguration.java new file mode 100644 index 0000000..226645b --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/config/IailabIdempotentConfiguration.java @@ -0,0 +1,46 @@ +package com.iailab.framework.idempotent.config; + +import com.iailab.framework.idempotent.core.aop.IdempotentAspect; +import com.iailab.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver; +import com.iailab.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver; +import com.iailab.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import com.iailab.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver; +import com.iailab.framework.idempotent.core.redis.IdempotentRedisDAO; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import com.iailab.framework.redis.config.IailabRedisAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.List; + +@AutoConfiguration(after = IailabRedisAutoConfiguration.class) +public class IailabIdempotentConfiguration { + + @Bean + public IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) { + return new IdempotentAspect(keyResolvers, idempotentRedisDAO); + } + + @Bean + public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) { + return new IdempotentRedisDAO(stringRedisTemplate); + } + + // ========== 各种 IdempotentKeyResolver Bean ========== + + @Bean + public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() { + return new DefaultIdempotentKeyResolver(); + } + + @Bean + public UserIdempotentKeyResolver userIdempotentKeyResolver() { + return new UserIdempotentKeyResolver(); + } + + @Bean + public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() { + return new ExpressionIdempotentKeyResolver(); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/annotation/Idempotent.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/annotation/Idempotent.java new file mode 100644 index 0000000..8686c0a --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/annotation/Idempotent.java @@ -0,0 +1,63 @@ +package com.iailab.framework.idempotent.core.annotation; + +import com.iailab.framework.idempotent.core.keyresolver.impl.DefaultIdempotentKeyResolver; +import com.iailab.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import com.iailab.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver; +import com.iailab.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * 幂等注解 + * + * @author iailab + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Idempotent { + + /** + * 幂等的超时时间,默认为 1 秒 + * + * 注意,如果执行时间超过它,请求还是会进来 + */ + int timeout() default 1; + /** + * 时间单位,默认为 SECONDS 秒 + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * 提示信息,正在执行中的提示 + */ + String message() default "重复请求,请稍后重试"; + + /** + * 使用的 Key 解析器 + * + * @see DefaultIdempotentKeyResolver 全局级别 + * @see UserIdempotentKeyResolver 用户级别 + * @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算 + */ + Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class; + /** + * 使用的 Key 参数 + */ + String keyArg() default ""; + + /** + * 删除 Key,当发生异常时候 + * + * 问题:为什么发生异常时,需要删除 Key 呢? + * 回答:发生异常时,说明业务发生错误,此时需要删除 Key,避免下次请求无法正常执行。 + * + * 问题:为什么不搞 deleteWhenSuccess 执行成功时,需要删除 Key 呢? + * 回答:这种情况下,本质上是分布式锁,推荐使用 @Lock4j 注解 + */ + boolean deleteKeyWhenException() default true; + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/aop/IdempotentAspect.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/aop/IdempotentAspect.java new file mode 100644 index 0000000..603f270 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/aop/IdempotentAspect.java @@ -0,0 +1,68 @@ +package com.iailab.framework.idempotent.core.aop; + +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.common.util.collection.CollectionUtils; +import com.iailab.framework.idempotent.core.annotation.Idempotent; +import com.iailab.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import com.iailab.framework.idempotent.core.redis.IdempotentRedisDAO; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; + +/** + * 拦截声明了 {@link Idempotent} 注解的方法,实现幂等操作 + * + * @author iailab + */ +@Aspect +@Slf4j +public class IdempotentAspect { + + /** + * IdempotentKeyResolver 集合 + */ + private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers; + + private final IdempotentRedisDAO idempotentRedisDAO; + + public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) { + this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass); + this.idempotentRedisDAO = idempotentRedisDAO; + } + + @Around(value = "@annotation(idempotent)") + public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { + // 获得 IdempotentKeyResolver + IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver()); + Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver"); + // 解析 Key + String key = keyResolver.resolver(joinPoint, idempotent); + + // 1. 锁定 Key + boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit()); + // 锁定失败,抛出异常 + if (!success) { + log.info("[aroundPointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs()); + throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message()); + } + + // 2. 执行逻辑 + try { + return joinPoint.proceed(); + } catch (Throwable throwable) { + // 3. 异常时,删除 Key + // 参考美团 GTIS 思路:https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html + if (idempotent.deleteKeyWhenException()) { + idempotentRedisDAO.delete(key); + } + throw throwable; + } + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java new file mode 100644 index 0000000..503957a --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/IdempotentKeyResolver.java @@ -0,0 +1,22 @@ +package com.iailab.framework.idempotent.core.keyresolver; + +import com.iailab.framework.idempotent.core.annotation.Idempotent; +import org.aspectj.lang.JoinPoint; + +/** + * 幂等 Key 解析器接口 + * + * @author iailab + */ +public interface IdempotentKeyResolver { + + /** + * 解析一个 Key + * + * @param idempotent 幂等注解 + * @param joinPoint AOP 切面 + * @return Key + */ + String resolver(JoinPoint joinPoint, Idempotent idempotent); + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java new file mode 100644 index 0000000..f9c5847 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/DefaultIdempotentKeyResolver.java @@ -0,0 +1,25 @@ +package com.iailab.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import com.iailab.framework.idempotent.core.annotation.Idempotent; +import com.iailab.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * 默认(全局级别)幂等 Key 解析器,使用方法名 + 方法参数,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author iailab + */ +public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, Idempotent idempotent) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtil.join(",", joinPoint.getArgs()); + return SecureUtil.md5(methodName + argsStr); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java new file mode 100644 index 0000000..b1fe1b5 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/ExpressionIdempotentKeyResolver.java @@ -0,0 +1,63 @@ +package com.iailab.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.core.util.ArrayUtil; +import com.iailab.framework.idempotent.core.annotation.Idempotent; +import com.iailab.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.LocalVariableTableParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import java.lang.reflect.Method; + +/** + * 基于 Spring EL 表达式, + * + * @author iailab + */ +public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver { + + private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer(); + private final ExpressionParser expressionParser = new SpelExpressionParser(); + + @Override + public String resolver(JoinPoint joinPoint, Idempotent idempotent) { + // 获得被拦截方法参数名列表 + Method method = getMethod(joinPoint); + Object[] args = joinPoint.getArgs(); + String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method); + // 准备 Spring EL 表达式解析的上下文 + StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + if (ArrayUtil.isNotEmpty(parameterNames)) { + for (int i = 0; i < parameterNames.length; i++) { + evaluationContext.setVariable(parameterNames[i], args[i]); + } + } + + // 解析参数 + Expression expression = expressionParser.parseExpression(idempotent.keyArg()); + return expression.getValue(evaluationContext, String.class); + } + + private static Method getMethod(JoinPoint point) { + // 处理,声明在类上的情况 + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + if (!method.getDeclaringClass().isInterface()) { + return method; + } + + // 处理,声明在接口上的情况 + try { + return point.getTarget().getClass().getDeclaredMethod( + point.getSignature().getName(), method.getParameterTypes()); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java new file mode 100644 index 0000000..22e29bc --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/keyresolver/impl/UserIdempotentKeyResolver.java @@ -0,0 +1,28 @@ +package com.iailab.framework.idempotent.core.keyresolver.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import com.iailab.framework.idempotent.core.annotation.Idempotent; +import com.iailab.framework.idempotent.core.keyresolver.IdempotentKeyResolver; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import org.aspectj.lang.JoinPoint; + +/** + * 用户级别的幂等 Key 解析器,使用方法名 + 方法参数 + userId + userType,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author iailab + */ +public class UserIdempotentKeyResolver implements IdempotentKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, Idempotent idempotent) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtil.join(",", joinPoint.getArgs()); + Long userId = WebFrameworkUtils.getLoginUserId(); + Integer userType = WebFrameworkUtils.getLoginUserType(); + return SecureUtil.md5(methodName + argsStr + userId + userType); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/redis/IdempotentRedisDAO.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/redis/IdempotentRedisDAO.java new file mode 100644 index 0000000..31b6b4b --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/core/redis/IdempotentRedisDAO.java @@ -0,0 +1,41 @@ +package com.iailab.framework.idempotent.core.redis; + +import lombok.AllArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.concurrent.TimeUnit; + +/** + * 幂等 Redis DAO + * + * @author iailab + */ +@AllArgsConstructor +public class IdempotentRedisDAO { + + /** + * 幂等操作 + * + * KEY 格式:idempotent:%s // 参数为 uuid + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String IDEMPOTENT = "idempotent:%s"; + + private final StringRedisTemplate redisTemplate; + + public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) { + String redisKey = formatKey(key); + return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit); + } + + public void delete(String key) { + String redisKey = formatKey(key); + redisTemplate.delete(redisKey); + } + + private static String formatKey(String key) { + return String.format(IDEMPOTENT, key); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/package-info.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/package-info.java new file mode 100644 index 0000000..052e4b7 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/idempotent/package-info.java @@ -0,0 +1,12 @@ +/** + * 幂等组件,参考 https://github.com/it4alla/idempotent 项目实现 + * 实现原理是,相同参数的方法,一段时间内,有且仅能执行一次。通过这样的方式,保证幂等性。 + * + * 使用场景:例如说,用户快速的双击了某个按钮,前端没有禁用该按钮,导致发送了两次重复的请求。 + * + * 和 it4alla/idempotent 组件的差异点,主要体现在两点: + * 1. 我们去掉了 @Idempotent 注解的 delKey 属性。原因是,本质上 delKey 为 true 时,实现的是分布式锁的能力 + * 此时,我们偏向使用 Lock4j 组件。原则上,一个组件只提供一种单一的能力。 + * 2. 考虑到组件的通用性,我们并未像 it4alla/idempotent 组件一样使用 Redisson RMap 结构,而是直接使用 Redis 的 String 数据格式。 + */ +package com.iailab.framework.idempotent; diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/config/IailabLock4jConfiguration.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/config/IailabLock4jConfiguration.java new file mode 100644 index 0000000..4e72e65 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/config/IailabLock4jConfiguration.java @@ -0,0 +1,18 @@ +package com.iailab.framework.lock4j.config; + +import com.iailab.framework.lock4j.core.DefaultLockFailureStrategy; +import com.baomidou.lock.spring.boot.autoconfigure.LockAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration(before = LockAutoConfiguration.class) +@ConditionalOnClass(name = "com.baomidou.lock.annotation.Lock4j") +public class IailabLock4jConfiguration { + + @Bean + public DefaultLockFailureStrategy lockFailureStrategy() { + return new DefaultLockFailureStrategy(); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/DefaultLockFailureStrategy.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/DefaultLockFailureStrategy.java new file mode 100644 index 0000000..d2187a7 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/DefaultLockFailureStrategy.java @@ -0,0 +1,21 @@ +package com.iailab.framework.lock4j.core; + +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.baomidou.lock.LockFailureStrategy; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Method; + +/** + * 自定义获取锁失败策略,抛出 {@link ServiceException} 异常 + */ +@Slf4j +public class DefaultLockFailureStrategy implements LockFailureStrategy { + + @Override + public void onLockFailure(String key, Method method, Object[] arguments) { + log.debug("[onLockFailure][线程:{} 获取锁失败,key:{} 获取失败:{} ]", Thread.currentThread().getName(), key, arguments); + throw new ServiceException(GlobalErrorCodeConstants.LOCKED); + } +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/Lock4jRedisKeyConstants.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/Lock4jRedisKeyConstants.java new file mode 100644 index 0000000..fbb076f --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/core/Lock4jRedisKeyConstants.java @@ -0,0 +1,19 @@ +package com.iailab.framework.lock4j.core; + +/** + * Lock4j Redis Key 枚举类 + * + * @author iailab + */ +public interface Lock4jRedisKeyConstants { + + /** + * 分布式锁 + * + * KEY 格式:lock4j:%s // 参数来自 DefaultLockKeyBuilder 类 + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 + * 过期时间:不固定 + */ + String LOCK4J = "lock4j:%s"; + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/package-info.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/package-info.java new file mode 100644 index 0000000..3016962 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/lock4j/package-info.java @@ -0,0 +1,4 @@ +/** + * 分布式锁组件,使用 https://gitee.com/baomidou/lock4j 开源项目 + */ +package com.iailab.framework.lock4j; diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/config/IailabRateLimiterConfiguration.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/config/IailabRateLimiterConfiguration.java new file mode 100644 index 0000000..4ce5009 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/config/IailabRateLimiterConfiguration.java @@ -0,0 +1,55 @@ +package com.iailab.framework.ratelimiter.config; + +import com.iailab.framework.ratelimiter.core.aop.RateLimiterAspect; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import com.iailab.framework.ratelimiter.core.keyresolver.impl.*; +import com.iailab.framework.ratelimiter.core.redis.RateLimiterRedisDAO; +import com.iailab.framework.redis.config.IailabRedisAutoConfiguration; +import org.redisson.api.RedissonClient; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.List; + +@AutoConfiguration(after = IailabRedisAutoConfiguration.class) +public class IailabRateLimiterConfiguration { + + @Bean + public RateLimiterAspect rateLimiterAspect(List<RateLimiterKeyResolver> keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) { + return new RateLimiterAspect(keyResolvers, rateLimiterRedisDAO); + } + + @Bean + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public RateLimiterRedisDAO rateLimiterRedisDAO(RedissonClient redissonClient) { + return new RateLimiterRedisDAO(redissonClient); + } + + // ========== 各种 RateLimiterRedisDAO Bean ========== + + @Bean + public DefaultRateLimiterKeyResolver defaultRateLimiterKeyResolver() { + return new DefaultRateLimiterKeyResolver(); + } + + @Bean + public UserRateLimiterKeyResolver userRateLimiterKeyResolver() { + return new UserRateLimiterKeyResolver(); + } + + @Bean + public ClientIpRateLimiterKeyResolver clientIpRateLimiterKeyResolver() { + return new ClientIpRateLimiterKeyResolver(); + } + + @Bean + public ServerNodeRateLimiterKeyResolver serverNodeRateLimiterKeyResolver() { + return new ServerNodeRateLimiterKeyResolver(); + } + + @Bean + public ExpressionRateLimiterKeyResolver expressionRateLimiterKeyResolver() { + return new ExpressionRateLimiterKeyResolver(); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/annotation/RateLimiter.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/annotation/RateLimiter.java new file mode 100644 index 0000000..1be2263 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/annotation/RateLimiter.java @@ -0,0 +1,62 @@ +package com.iailab.framework.ratelimiter.core.annotation; + +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.idempotent.core.keyresolver.impl.ExpressionIdempotentKeyResolver; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import com.iailab.framework.ratelimiter.core.keyresolver.impl.ClientIpRateLimiterKeyResolver; +import com.iailab.framework.ratelimiter.core.keyresolver.impl.DefaultRateLimiterKeyResolver; +import com.iailab.framework.ratelimiter.core.keyresolver.impl.ServerNodeRateLimiterKeyResolver; +import com.iailab.framework.ratelimiter.core.keyresolver.impl.UserRateLimiterKeyResolver; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * 限流注解 + * + * @author iailab + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimiter { + + /** + * 限流的时间,默认为 1 秒 + */ + int time() default 1; + /** + * 时间单位,默认为 SECONDS 秒 + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * 限流次数 + */ + int count() default 100; + + /** + * 提示信息,请求过快的提示 + * + * @see GlobalErrorCodeConstants#TOO_MANY_REQUESTS + */ + String message() default ""; // 为空时,使用 TOO_MANY_REQUESTS 错误提示 + + /** + * 使用的 Key 解析器 + * + * @see DefaultRateLimiterKeyResolver 全局级别 + * @see UserRateLimiterKeyResolver 用户 ID 级别 + * @see ClientIpRateLimiterKeyResolver 用户 IP 级别 + * @see ServerNodeRateLimiterKeyResolver 服务器 Node 级别 + * @see ExpressionIdempotentKeyResolver 自定义表达式,通过 {@link #keyArg()} 计算 + */ + Class<? extends RateLimiterKeyResolver> keyResolver() default DefaultRateLimiterKeyResolver.class; + /** + * 使用的 Key 参数 + */ + String keyArg() default ""; + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/aop/RateLimiterAspect.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/aop/RateLimiterAspect.java new file mode 100644 index 0000000..1ca33d2 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/aop/RateLimiterAspect.java @@ -0,0 +1,60 @@ +package com.iailab.framework.ratelimiter.core.aop; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.common.util.collection.CollectionUtils; +import com.iailab.framework.ratelimiter.core.annotation.RateLimiter; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import com.iailab.framework.ratelimiter.core.redis.RateLimiterRedisDAO; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.util.Assert; + +import java.util.List; +import java.util.Map; + +/** + * 拦截声明了 {@link RateLimiter} 注解的方法,实现限流操作 + * + * @author iailab + */ +@Aspect +@Slf4j +public class RateLimiterAspect { + + /** + * RateLimiterKeyResolver 集合 + */ + private final Map<Class<? extends RateLimiterKeyResolver>, RateLimiterKeyResolver> keyResolvers; + + private final RateLimiterRedisDAO rateLimiterRedisDAO; + + public RateLimiterAspect(List<RateLimiterKeyResolver> keyResolvers, RateLimiterRedisDAO rateLimiterRedisDAO) { + this.keyResolvers = CollectionUtils.convertMap(keyResolvers, RateLimiterKeyResolver::getClass); + this.rateLimiterRedisDAO = rateLimiterRedisDAO; + } + + @Before("@annotation(rateLimiter)") + public void beforePointCut(JoinPoint joinPoint, RateLimiter rateLimiter) { + // 获得 IdempotentKeyResolver 对象 + RateLimiterKeyResolver keyResolver = keyResolvers.get(rateLimiter.keyResolver()); + Assert.notNull(keyResolver, "找不到对应的 RateLimiterKeyResolver"); + // 解析 Key + String key = keyResolver.resolver(joinPoint, rateLimiter); + + // 获取 1 次限流 + boolean success = rateLimiterRedisDAO.tryAcquire(key, + rateLimiter.count(), rateLimiter.time(), rateLimiter.timeUnit()); + if (!success) { + log.info("[beforePointCut][方法({}) 参数({}) 请求过于频繁]", joinPoint.getSignature().toString(), joinPoint.getArgs()); + String message = StrUtil.blankToDefault(rateLimiter.message(), + GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getMsg()); + throw new ServiceException(GlobalErrorCodeConstants.TOO_MANY_REQUESTS.getCode(), message); + } + } + +} + diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/RateLimiterKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/RateLimiterKeyResolver.java new file mode 100644 index 0000000..e72d70d --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/RateLimiterKeyResolver.java @@ -0,0 +1,22 @@ +package com.iailab.framework.ratelimiter.core.keyresolver; + +import com.iailab.framework.ratelimiter.core.annotation.RateLimiter; +import org.aspectj.lang.JoinPoint; + +/** + * 限流 Key 解析器接口 + * + * @author iailab + */ +public interface RateLimiterKeyResolver { + + /** + * 解析一个 Key + * + * @param rateLimiter 限流注解 + * @param joinPoint AOP 切面 + * @return Key + */ + String resolver(JoinPoint joinPoint, RateLimiter rateLimiter); + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java new file mode 100644 index 0000000..2e9be94 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ClientIpRateLimiterKeyResolver.java @@ -0,0 +1,27 @@ +package com.iailab.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.ratelimiter.core.annotation.RateLimiter; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * IP 级别的限流 Key 解析器,使用方法名 + 方法参数 + IP,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author iailab + */ +public class ClientIpRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String clientIp = ServletUtils.getClientIP(); + return SecureUtil.md5(methodName + argsStr + clientIp); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java new file mode 100644 index 0000000..1b3af97 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/DefaultRateLimiterKeyResolver.java @@ -0,0 +1,25 @@ +package com.iailab.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import com.iailab.framework.ratelimiter.core.annotation.RateLimiter; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * 默认(全局级别)限流 Key 解析器,使用方法名 + 方法参数,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author iailab + */ +public class DefaultRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtil.join(",", joinPoint.getArgs()); + return SecureUtil.md5(methodName + argsStr); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ExpressionRateLimiterKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ExpressionRateLimiterKeyResolver.java new file mode 100644 index 0000000..813daa3 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ExpressionRateLimiterKeyResolver.java @@ -0,0 +1,64 @@ +package com.iailab.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.core.util.ArrayUtil; +import com.iailab.framework.ratelimiter.core.annotation.RateLimiter; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import java.lang.reflect.Method; + +/** + * 基于 Spring EL 表达式的 {@link RateLimiterKeyResolver} 实现类 + * + * @author iailab + */ +public class ExpressionRateLimiterKeyResolver implements RateLimiterKeyResolver { + + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + private final ExpressionParser expressionParser = new SpelExpressionParser(); + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + // 获得被拦截方法参数名列表 + Method method = getMethod(joinPoint); + Object[] args = joinPoint.getArgs(); + String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method); + // 准备 Spring EL 表达式解析的上下文 + StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); + if (ArrayUtil.isNotEmpty(parameterNames)) { + for (int i = 0; i < parameterNames.length; i++) { + evaluationContext.setVariable(parameterNames[i], args[i]); + } + } + + // 解析参数 + Expression expression = expressionParser.parseExpression(rateLimiter.keyArg()); + return expression.getValue(evaluationContext, String.class); + } + + private static Method getMethod(JoinPoint point) { + // 处理,声明在类上的情况 + MethodSignature signature = (MethodSignature) point.getSignature(); + Method method = signature.getMethod(); + if (!method.getDeclaringClass().isInterface()) { + return method; + } + + // 处理,声明在接口上的情况 + try { + return point.getTarget().getClass().getDeclaredMethod( + point.getSignature().getName(), method.getParameterTypes()); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java new file mode 100644 index 0000000..16775f1 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/ServerNodeRateLimiterKeyResolver.java @@ -0,0 +1,27 @@ +package com.iailab.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.system.SystemUtil; +import com.iailab.framework.ratelimiter.core.annotation.RateLimiter; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import org.aspectj.lang.JoinPoint; + +/** + * Server 节点级别的限流 Key 解析器,使用方法名 + 方法参数 + IP,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author iailab + */ +public class ServerNodeRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtil.join(",", joinPoint.getArgs()); + String serverNode = String.format("%s@%d", SystemUtil.getHostInfo().getAddress(), SystemUtil.getCurrentPID()); + return SecureUtil.md5(methodName + argsStr + serverNode); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java new file mode 100644 index 0000000..9fed5cc --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/keyresolver/impl/UserRateLimiterKeyResolver.java @@ -0,0 +1,28 @@ +package com.iailab.framework.ratelimiter.core.keyresolver.impl; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import com.iailab.framework.ratelimiter.core.annotation.RateLimiter; +import com.iailab.framework.ratelimiter.core.keyresolver.RateLimiterKeyResolver; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import org.aspectj.lang.JoinPoint; + +/** + * 用户级别的限流 Key 解析器,使用方法名 + 方法参数 + userId + userType,组装成一个 Key + * + * 为了避免 Key 过长,使用 MD5 进行“压缩” + * + * @author iailab + */ +public class UserRateLimiterKeyResolver implements RateLimiterKeyResolver { + + @Override + public String resolver(JoinPoint joinPoint, RateLimiter rateLimiter) { + String methodName = joinPoint.getSignature().toString(); + String argsStr = StrUtil.join(",", joinPoint.getArgs()); + Long userId = WebFrameworkUtils.getLoginUserId(); + Integer userType = WebFrameworkUtils.getLoginUserType(); + return SecureUtil.md5(methodName + argsStr + userId + userType); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java new file mode 100644 index 0000000..5a113c3 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/core/redis/RateLimiterRedisDAO.java @@ -0,0 +1,60 @@ +package com.iailab.framework.ratelimiter.core.redis; + +import lombok.AllArgsConstructor; +import org.redisson.api.*; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * 限流 Redis DAO + * + * @author iailab + */ +@AllArgsConstructor +public class RateLimiterRedisDAO { + + /** + * 限流操作 + * + * KEY 格式:rate_limiter:%s // 参数为 uuid + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String RATE_LIMITER = "rate_limiter:%s"; + + private final RedissonClient redissonClient; + + public Boolean tryAcquire(String key, int count, int time, TimeUnit timeUnit) { + // 1. 获得 RRateLimiter,并设置 rate 速率 + RRateLimiter rateLimiter = getRRateLimiter(key, count, time, timeUnit); + // 2. 尝试获取 1 个 + return rateLimiter.tryAcquire(); + } + + private static String formatKey(String key) { + return String.format(RATE_LIMITER, key); + } + + private RRateLimiter getRRateLimiter(String key, long count, int time, TimeUnit timeUnit) { + String redisKey = formatKey(key); + RRateLimiter rateLimiter = redissonClient.getRateLimiter(redisKey); + long rateInterval = timeUnit.toSeconds(time); + // 1. 如果不存在,设置 rate 速率 + RateLimiterConfig config = rateLimiter.getConfig(); + if (config == null) { + rateLimiter.trySetRate(RateType.OVERALL, count, rateInterval, RateIntervalUnit.SECONDS); + return rateLimiter; + } + // 2. 如果存在,并且配置相同,则直接返回 + if (config.getRateType() == RateType.OVERALL + && Objects.equals(config.getRate(), count) + && Objects.equals(config.getRateInterval(), TimeUnit.SECONDS.toMillis(rateInterval))) { + return rateLimiter; + } + // 3. 如果存在,并且配置不同,则进行新建 + rateLimiter.setRate(RateType.OVERALL, count, rateInterval, RateIntervalUnit.SECONDS); + return rateLimiter; + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/package-info.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/package-info.java new file mode 100644 index 0000000..fc519ac --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/ratelimiter/package-info.java @@ -0,0 +1,4 @@ +/** + * 限流组件,基于 Redisson {@link org.redisson.api.RRateLimiter} 限流实现 + */ +package com.iailab.framework.ratelimiter; \ No newline at end of file diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/config/IailabApiSignatureAutoConfiguration.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/config/IailabApiSignatureAutoConfiguration.java new file mode 100644 index 0000000..a036906 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/config/IailabApiSignatureAutoConfiguration.java @@ -0,0 +1,28 @@ +package com.iailab.framework.signature.config; + +import com.iailab.framework.redis.config.IailabRedisAutoConfiguration; +import com.iailab.framework.signature.core.aop.ApiSignatureAspect; +import com.iailab.framework.signature.core.redis.ApiSignatureRedisDAO; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * HTTP API 签名的自动配置类 + * + * @author Zhougang + */ +@AutoConfiguration(after = IailabRedisAutoConfiguration.class) +public class IailabApiSignatureAutoConfiguration { + + @Bean + public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) { + return new ApiSignatureAspect(signatureRedisDAO); + } + + @Bean + public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) { + return new ApiSignatureRedisDAO(stringRedisTemplate); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/annotation/ApiSignature.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/annotation/ApiSignature.java new file mode 100644 index 0000000..c0a263b --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/annotation/ApiSignature.java @@ -0,0 +1,59 @@ +package com.iailab.framework.signature.core.annotation; + +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; + +import java.lang.annotation.*; +import java.util.concurrent.TimeUnit; + + +/** + * HTTP API 签名注解 + * + * @author Zhougang + */ +@Inherited +@Documented +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiSignature { + + /** + * 同一个请求多长时间内有效 默认 60 秒 + */ + int timeout() default 60; + + /** + * 时间单位,默认为 SECONDS 秒 + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + // ========================== 签名参数 ========================== + + /** + * 提示信息,签名失败的提示 + * + * @see GlobalErrorCodeConstants#BAD_REQUEST + */ + String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示 + + /** + * 签名字段:appId 应用ID + */ + String appId() default "appId"; + + /** + * 签名字段:timestamp 时间戳 + */ + String timestamp() default "timestamp"; + + /** + * 签名字段:nonce 随机数,10 位以上 + */ + String nonce() default "nonce"; + + /** + * sign 客户端签名 + */ + String sign() default "sign"; + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/aop/ApiSignatureAspect.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/aop/ApiSignatureAspect.java new file mode 100644 index 0000000..716f0de --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/aop/ApiSignatureAspect.java @@ -0,0 +1,169 @@ +package com.iailab.framework.signature.core.aop; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.signature.core.annotation.ApiSignature; +import com.iailab.framework.signature.core.redis.ApiSignatureRedisDAO; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; + +import javax.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.Objects; +import java.util.SortedMap; +import java.util.TreeMap; + +import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; + +/** + * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名 + * + * @author Zhougang + */ +@Aspect +@Slf4j +@AllArgsConstructor +public class ApiSignatureAspect { + + private final ApiSignatureRedisDAO signatureRedisDAO; + + @Before("@annotation(signature)") + public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) { + // 1. 验证通过,直接结束 + if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) { + return; + } + + // 2. 验证不通过,抛出异常 + log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(), + joinPoint.getArgs()); + throw new ServiceException(BAD_REQUEST.getCode(), + StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg())); + } + + public boolean verifySignature(ApiSignature signature, HttpServletRequest request) { + // 1.1 校验 Header + if (!verifyHeaders(signature, request)) { + return false; + } + // 1.2 校验 appId 是否能获取到对应的 appSecret + String appId = request.getHeader(signature.appId()); + String appSecret = signatureRedisDAO.getAppSecret(appId); + Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId); + + // 2. 校验签名【重要!】 + String clientSignature = request.getHeader(signature.sign()); // 客户端签名 + String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串 + String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名 + if (ObjUtil.notEqual(clientSignature, serverSignature)) { + return false; + } + + // 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 ) + String nonce = request.getHeader(signature.nonce()); + signatureRedisDAO.setNonce(nonce, signature.timeout() * 2, signature.timeUnit()); + return true; + } + + /** + * 校验请求头加签参数 + * + * 1. appId 是否为空 + * 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟 + * 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了 + * 4. sign 是否为空 + * + * @param signature signature + * @param request request + * @return 是否校验 Header 通过 + */ + private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) { + // 1. 非空校验 + String appId = request.getHeader(signature.appId()); + if (StrUtil.isBlank(appId)) { + return false; + } + String timestamp = request.getHeader(signature.timestamp()); + if (StrUtil.isBlank(timestamp)) { + return false; + } + String nonce = request.getHeader(signature.nonce()); + if (StrUtil.length(nonce) < 10) { + return false; + } + String sign = request.getHeader(signature.sign()); + if (StrUtil.isBlank(sign)) { + return false; + } + + // 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值) + long expireTime = signature.timeUnit().toMillis(signature.timeout()); + long requestTimestamp = Long.parseLong(timestamp); + long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp); + if (timestampDisparity > expireTime) { + return false; + } + + // 3. 检查 nonce 是否存在,有且仅能使用一次 + return signatureRedisDAO.getNonce(nonce) == null; + } + + /** + * 构建签名字符串 + * + * 格式为 = 请求参数 + 请求体 + 请求头 + 密钥 + * + * @param signature signature + * @param request request + * @param appSecret appSecret + * @return 签名字符串 + */ + private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) { + SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头 + SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数 + String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体 + return MapUtil.join(parameterMap, "&", "=") + + requestBody + + MapUtil.join(headerMap, "&", "=") + + appSecret; + } + + /** + * 获取请求头加签参数 Map + * + * @param request 请求 + * @param signature 签名注解 + * @return signature params + */ + private static SortedMap<String, String> getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) { + SortedMap<String, String> sortedMap = new TreeMap<>(); + sortedMap.put(signature.appId(), request.getHeader(signature.appId())); + sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp())); + sortedMap.put(signature.nonce(), request.getHeader(signature.nonce())); + return sortedMap; + } + + /** + * 获取请求参数 Map + * + * @param request 请求 + * @return queryParams + */ + private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) { + SortedMap<String, String> sortedMap = new TreeMap<>(); + for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) { + sortedMap.put(entry.getKey(), entry.getValue()[0]); + } + return sortedMap; + } + +} + diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/redis/ApiSignatureRedisDAO.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/redis/ApiSignatureRedisDAO.java new file mode 100644 index 0000000..b6c8c8e --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/core/redis/ApiSignatureRedisDAO.java @@ -0,0 +1,57 @@ +package com.iailab.framework.signature.core.redis; + +import lombok.AllArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.concurrent.TimeUnit; + +/** + * HTTP API 签名 Redis DAO + * + * @author Zhougang + */ +@AllArgsConstructor +public class ApiSignatureRedisDAO { + + private final StringRedisTemplate stringRedisTemplate; + + /** + * 验签随机数 + * + * KEY 格式:signature_nonce:%s // 参数为 随机数 + * VALUE 格式:String + * 过期时间:不固定 + */ + private static final String SIGNATURE_NONCE = "api_signature_nonce:%s"; + + /** + * 签名密钥 + * + * HASH 结构 + * KEY 格式:%s // 参数为 appid + * VALUE 格式:String + * 过期时间:永不过期(预加载到 Redis) + */ + private static final String SIGNATURE_APPID = "api_signature_app"; + + // ========== 验签随机数 ========== + + public String getNonce(String nonce) { + return stringRedisTemplate.opsForValue().get(formatNonceKey(nonce)); + } + + public void setNonce(String nonce, int time, TimeUnit timeUnit) { + stringRedisTemplate.opsForValue().set(formatNonceKey(nonce), "", time, timeUnit); + } + + private static String formatNonceKey(String key) { + return String.format(SIGNATURE_NONCE, key); + } + + // ========== 签名密钥 ========== + + public String getAppSecret(String appId) { + return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId); + } + +} diff --git a/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/package-info.java b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/package-info.java new file mode 100644 index 0000000..1f5f8ac --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/java/com/iailab/framework/signature/package-info.java @@ -0,0 +1,6 @@ +/** + * HTTP API 签名,校验安全性 + * + * @see <a href="https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3>微信支付 —— 安全规范</a> + */ +package com.iailab.framework.signature; \ No newline at end of file diff --git a/iailab-framework/iailab-common-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..85259d2 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +com.iailab.framework.idempotent.config.IailabIdempotentConfiguration +com.iailab.framework.lock4j.config.IailabLock4jConfiguration +com.iailab.framework.ratelimiter.config.IailabRateLimiterConfiguration \ No newline at end of file diff --git a/iailab-framework/iailab-common-protection/src/test/java/com/iailab/framework/signature/core/ApiSignatureTest.java b/iailab-framework/iailab-common-protection/src/test/java/com/iailab/framework/signature/core/ApiSignatureTest.java new file mode 100644 index 0000000..8c94e79 --- /dev/null +++ b/iailab-framework/iailab-common-protection/src/test/java/com/iailab/framework/signature/core/ApiSignatureTest.java @@ -0,0 +1,75 @@ +package com.iailab.framework.signature.core; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.iailab.framework.signature.core.annotation.ApiSignature; +import com.iailab.framework.signature.core.aop.ApiSignatureAspect; +import com.iailab.framework.signature.core.redis.ApiSignatureRedisDAO; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.servlet.http.HttpServletRequest; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link ApiSignatureTest} 的单元测试 + */ +@ExtendWith(MockitoExtension.class) +public class ApiSignatureTest { + + @InjectMocks + private ApiSignatureAspect apiSignatureAspect; + + @Mock + private ApiSignatureRedisDAO signatureRedisDAO; + + @Test + public void testSignatureGet() throws IOException { + // 搞一个签名 + Long timestamp = System.currentTimeMillis(); + String nonce = IdUtil.randomUUID(); + String appId = "xxxxxx"; + String appSecret = "yyyyyy"; + String signString = "k1=v1&v1=k1testappId=xxxxxx&nonce=" + nonce + "×tamp=" + timestamp + "yyyyyy"; + String sign = DigestUtil.sha256Hex(signString); + + // 准备参数 + ApiSignature apiSignature = mock(ApiSignature.class); + when(apiSignature.appId()).thenReturn("appId"); + when(apiSignature.timestamp()).thenReturn("timestamp"); + when(apiSignature.nonce()).thenReturn("nonce"); + when(apiSignature.sign()).thenReturn("sign"); + when(apiSignature.timeout()).thenReturn(60); + when(apiSignature.timeUnit()).thenReturn(TimeUnit.SECONDS); + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader(eq("appId"))).thenReturn(appId); + when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp)); + when(request.getHeader(eq("nonce"))).thenReturn(nonce); + when(request.getHeader(eq("sign"))).thenReturn(sign); + when(request.getParameterMap()).thenReturn(MapUtil.<String, String[]>builder() + .put("v1", new String[]{"k1"}).put("k1", new String[]{"v1"}).build()); + when(request.getContentType()).thenReturn("application/json"); + when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test"))); + // mock 方法 + when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret); + + // 调用 + boolean result = apiSignatureAspect.verifySignature(apiSignature, request); + // 断言结果 + assertTrue(result); + // 断言调用 + verify(signatureRedisDAO).setNonce(eq(nonce), eq(120), eq(TimeUnit.SECONDS)); + } + +} diff --git a/iailab-framework/iailab-common-redis/pom.xml b/iailab-framework/iailab-common-redis/pom.xml new file mode 100644 index 0000000..5940dae --- /dev/null +++ b/iailab-framework/iailab-common-redis/pom.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-redis</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>Redis 封装拓展</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- DB 相关 --> + <dependency> + <groupId>org.redisson</groupId> + <artifactId>redisson-spring-boot-starter</artifactId> + </dependency> + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-cache</artifactId> <!-- 实现对 Caches 的自动化配置 --> + </dependency> + + <dependency> + <groupId>com.fasterxml.jackson.datatype</groupId> + <artifactId>jackson-datatype-jsr310</artifactId> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheAutoConfiguration.java b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheAutoConfiguration.java new file mode 100644 index 0000000..167d147 --- /dev/null +++ b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheAutoConfiguration.java @@ -0,0 +1,82 @@ +package com.iailab.framework.redis.config; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.redis.core.TimeoutRedisCacheManager; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.cache.CacheProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.BatchStrategies; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.util.StringUtils; + +import java.util.Objects; + +import static com.iailab.framework.redis.config.IailabRedisAutoConfiguration.buildRedisSerializer; + +/** + * Cache 配置类,基于 Redis 实现 + */ +@AutoConfiguration +@EnableConfigurationProperties({CacheProperties.class, IailabCacheProperties.class}) +@EnableCaching +public class IailabCacheAutoConfiguration { + + /** + * RedisCacheConfiguration Bean + * <p> + * 参考 org.springframework.boot.autoconfigure.cache.RedisCacheConfiguration 的 createConfiguration 方法 + */ + @Bean + @Primary + public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig(); + // 设置使用 : 单冒号,而不是双 :: 冒号,避免 Redis Desktop Manager 多余空格 + // 详细可见 https://blog.csdn.net/chuixue24/article/details/103928965 博客 + // 再次修复单冒号,而不是双 :: 冒号问题,Issues 详情:https://gitee.com/zhijiantianya/iailab-cloud/issues/I86VY2 + config = config.computePrefixWith(cacheName -> { + String keyPrefix = cacheProperties.getRedis().getKeyPrefix(); + if (StringUtils.hasText(keyPrefix)) { + keyPrefix = keyPrefix.lastIndexOf(StrUtil.COLON) == -1 ? keyPrefix + StrUtil.COLON : keyPrefix; + return keyPrefix + cacheName + StrUtil.COLON; + } + return cacheName + StrUtil.COLON; + }); + // 设置使用 JSON 序列化方式 + config = config.serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(buildRedisSerializer())); + + // 设置 CacheProperties.Redis 的属性 + CacheProperties.Redis redisProperties = cacheProperties.getRedis(); + if (redisProperties.getTimeToLive() != null) { + config = config.entryTtl(redisProperties.getTimeToLive()); + } + if (!redisProperties.isCacheNullValues()) { + config = config.disableCachingNullValues(); + } + if (!redisProperties.isUseKeyPrefix()) { + config = config.disableKeyPrefix(); + } + return config; + } + + @Bean + public RedisCacheManager redisCacheManager(RedisTemplate<String, Object> redisTemplate, + RedisCacheConfiguration redisCacheConfiguration, + IailabCacheProperties iailabCacheProperties) { + // 创建 RedisCacheWriter 对象 + RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory()); + RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory, + BatchStrategies.scan(iailabCacheProperties.getRedisScanBatchSize())); + // 创建 TenantRedisCacheManager 对象 + return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration); + } + +} diff --git a/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheProperties.java b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheProperties.java new file mode 100644 index 0000000..2cc155f --- /dev/null +++ b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabCacheProperties.java @@ -0,0 +1,27 @@ +package com.iailab.framework.redis.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Cache 配置项 + * + * @author Wanwan + */ +@ConfigurationProperties("iailab.cache") +@Data +@Validated +public class IailabCacheProperties { + + /** + * {@link #redisScanBatchSize} 默认值 + */ + private static final Integer REDIS_SCAN_BATCH_SIZE_DEFAULT = 30; + + /** + * redis scan 一次返回数量 + */ + private Integer redisScanBatchSize = REDIS_SCAN_BATCH_SIZE_DEFAULT; + +} diff --git a/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabRedisAutoConfiguration.java b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabRedisAutoConfiguration.java new file mode 100644 index 0000000..e63df54 --- /dev/null +++ b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/config/IailabRedisAutoConfiguration.java @@ -0,0 +1,45 @@ +package com.iailab.framework.redis.config; + +import cn.hutool.core.util.ReflectUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.redisson.spring.starter.RedissonAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; + +/** + * Redis 配置类 + */ +@AutoConfiguration(before = RedissonAutoConfiguration.class) // 目的:使用自己定义的 RedisTemplate Bean +public class IailabRedisAutoConfiguration { + + /** + * 创建 RedisTemplate Bean,使用 JSON 序列化方式 + */ + @Bean + public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { + // 创建 RedisTemplate 对象 + RedisTemplate<String, Object> template = new RedisTemplate<>(); + // 设置 RedisConnection 工厂。😈 它就是实现多种 Java Redis 客户端接入的秘密工厂。感兴趣的胖友,可以自己去撸下。 + template.setConnectionFactory(factory); + // 使用 String 序列化方式,序列化 KEY 。 + template.setKeySerializer(RedisSerializer.string()); + template.setHashKeySerializer(RedisSerializer.string()); + // 使用 JSON 序列化方式(库是 Jackson ),序列化 VALUE 。 + template.setValueSerializer(buildRedisSerializer()); + template.setHashValueSerializer(buildRedisSerializer()); + return template; + } + + public static RedisSerializer<?> buildRedisSerializer() { + RedisSerializer<Object> json = RedisSerializer.json(); + // 解决 LocalDateTime 的序列化 + ObjectMapper objectMapper = (ObjectMapper) ReflectUtil.getFieldValue(json, "mapper"); + objectMapper.registerModules(new JavaTimeModule()); + return json; + } + +} diff --git a/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/core/TimeoutRedisCacheManager.java b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/core/TimeoutRedisCacheManager.java new file mode 100644 index 0000000..de86678 --- /dev/null +++ b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/core/TimeoutRedisCacheManager.java @@ -0,0 +1,86 @@ +package com.iailab.framework.redis.core; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.redis.cache.RedisCache; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.cache.RedisCacheWriter; + +import java.time.Duration; + +/** + * 支持自定义过期时间的 {@link RedisCacheManager} 实现类 + * + * 在 {@link Cacheable#cacheNames()} 格式为 "key#ttl" 时,# 后面的 ttl 为过期时间。 + * 单位为最后一个字母(支持的单位有:d 天,h 小时,m 分钟,s 秒),默认单位为 s 秒 + * + * @author iailab + */ +public class TimeoutRedisCacheManager extends RedisCacheManager { + + private static final String SPLIT = "#"; + + public TimeoutRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) { + super(cacheWriter, defaultCacheConfiguration); + } + + @Override + protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) { + if (StrUtil.isEmpty(name)) { + return super.createRedisCache(name, cacheConfig); + } + // 如果使用 # 分隔,大小不为 2,则说明不使用自定义过期时间 + String[] names = StrUtil.splitToArray(name, SPLIT); + if (names.length != 2) { + return super.createRedisCache(name, cacheConfig); + } + + // 核心:通过修改 cacheConfig 的过期时间,实现自定义过期时间 + if (cacheConfig != null) { + // 移除 # 后面的 : 以及后面的内容,避免影响解析 + String ttlStr = StrUtil.subBefore(names[1], StrUtil.COLON, false); // 获得 ttlStr 时间部分 + names[1] = StrUtil.subAfter(names[1], ttlStr, false); // 移除掉 ttlStr 时间部分 + // 解析时间 + Duration duration = parseDuration(ttlStr); + cacheConfig = cacheConfig.entryTtl(duration); + } + + // 创建 RedisCache 对象,需要忽略掉 ttlStr + return super.createRedisCache(names[0] + names[1], cacheConfig); + } + + /** + * 解析过期时间 Duration + * + * @param ttlStr 过期时间字符串 + * @return 过期时间 Duration + */ + private Duration parseDuration(String ttlStr) { + String timeUnit = StrUtil.subSuf(ttlStr, -1); + switch (timeUnit) { + case "d": + return Duration.ofDays(removeDurationSuffix(ttlStr)); + case "h": + return Duration.ofHours(removeDurationSuffix(ttlStr)); + case "m": + return Duration.ofMinutes(removeDurationSuffix(ttlStr)); + case "s": + return Duration.ofSeconds(removeDurationSuffix(ttlStr)); + default: + return Duration.ofSeconds(Long.parseLong(ttlStr)); + } + } + + /** + * 移除多余的后缀,返回具体的时间 + * + * @param ttlStr 过期时间字符串 + * @return 时间 + */ + private Long removeDurationSuffix(String ttlStr) { + return NumberUtil.parseLong(StrUtil.sub(ttlStr, 0, ttlStr.length() - 1)); + } + +} diff --git a/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/package-info.java b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/package-info.java new file mode 100644 index 0000000..6ce6b3a --- /dev/null +++ b/iailab-framework/iailab-common-redis/src/main/java/com/iailab/framework/redis/package-info.java @@ -0,0 +1,4 @@ +/** + * 采用 Spring Data Redis 操作 Redis,底层使用 Redisson 作为客户端 + */ +package com.iailab.framework.redis; diff --git a/iailab-framework/iailab-common-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..1b9e718 --- /dev/null +++ b/iailab-framework/iailab-common-redis/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.iailab.framework.redis.config.IailabRedisAutoConfiguration +com.iailab.framework.redis.config.IailabCacheAutoConfiguration diff --git a/iailab-framework/iailab-common-rpc/pom.xml b/iailab-framework/iailab-common-rpc/pom.xml new file mode 100644 index 0000000..d27e89a --- /dev/null +++ b/iailab-framework/iailab-common-rpc/pom.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <artifactId>iailab-framework</artifactId> + <groupId>com.iailab</groupId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-rpc</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description> + OpenFeign:提供 RESTful API 的调用 + </description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- RPC 远程调用相关 --> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-loadbalancer</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.cloud</groupId> + <artifactId>spring-cloud-starter-openfeign</artifactId> + </dependency> + <dependency> + <groupId>io.github.openfeign</groupId> + <artifactId>feign-okhttp</artifactId> + </dependency> + + <!-- 工具相关 --> + <dependency> + <groupId>jakarta.validation</groupId> + <artifactId>jakarta.validation-api</artifactId> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/config/package-info.java b/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/config/package-info.java new file mode 100644 index 0000000..60ee765 --- /dev/null +++ b/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/config/package-info.java @@ -0,0 +1,4 @@ +/** + * 占坑 TODO + */ +package com.iailab.framework.rpc.config; diff --git a/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/core/package-info.java b/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/core/package-info.java new file mode 100644 index 0000000..c89314e --- /dev/null +++ b/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占坑 TODO + */ +package com.iailab.framework.rpc.core; diff --git a/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/package-info.java b/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/package-info.java new file mode 100644 index 0000000..b263430 --- /dev/null +++ b/iailab-framework/iailab-common-rpc/src/main/java/com/iailab/framework/rpc/package-info.java @@ -0,0 +1,6 @@ +/** + * OpenFeign:提供 RESTful API 的调用 + * + * @author iailab + */ +package com.iailab.framework.rpc; diff --git a/iailab-framework/iailab-common-security/pom.xml b/iailab-framework/iailab-common-security/pom.xml new file mode 100644 index 0000000..feb7855 --- /dev/null +++ b/iailab-framework/iailab-common-security/pom.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-security</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description> + 1. security:用户的认证、权限的校验,实现「谁」可以做「什么事」 + 2. operatelog:操作日志,实现「谁」在「什么时间」对「什么」做了「什么事」 + </description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Spring 核心 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-aop</artifactId> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-web</artifactId> + </dependency> + <!-- spring boot 配置所需依赖 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <optional>true</optional> + </dependency> + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-security</artifactId> + </dependency> + + <!-- RPC 远程调用相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-rpc</artifactId> + <optional>true</optional> + </dependency> + + <!-- 业务组件 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-module-system-api</artifactId> <!-- 需要使用它,进行 Token 的校验 --> + <version>${revision}</version> + </dependency> + + <!-- 工具类相关 --> + <dependency> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> + </dependency> + + <dependency> + <!-- Spring Boot 通用操作日志组件,基于注解实现 --> + <!-- 此组件解决的问题是:「谁」在「什么时间」对「什么」做了「什么事」 --> + <groupId>io.github.mouzt</groupId> + <artifactId>bizlog-sdk</artifactId> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogConfiguration.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogConfiguration.java new file mode 100644 index 0000000..22b90b8 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogConfiguration.java @@ -0,0 +1,27 @@ +package com.iailab.framework.operatelog.config; + +import com.iailab.framework.operatelog.core.service.LogRecordServiceImpl; +import com.mzt.logapi.service.ILogRecordService; +import com.mzt.logapi.starter.annotation.EnableLogRecord; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +/** + * 操作日志配置类 + * + * @author HUIHUI + */ +@EnableLogRecord(tenant = "") // 貌似用不上 tenant 这玩意给个空好啦 +@AutoConfiguration +@Slf4j +public class IailabOperateLogConfiguration { + + @Bean + @Primary + public ILogRecordService iLogRecordServiceImpl() { + return new LogRecordServiceImpl(); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogRpcAutoConfiguration.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogRpcAutoConfiguration.java new file mode 100644 index 0000000..ef72d29 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/config/IailabOperateLogRpcAutoConfiguration.java @@ -0,0 +1,15 @@ +package com.iailab.framework.operatelog.config; + +import com.iailab.module.system.api.logger.OperateLogApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; + +/** + * OperateLog 使用到 Feign 的配置项 + * + * @author iailab + */ +@AutoConfiguration +@EnableFeignClients(clients = {OperateLogApi.class}) // 主要是引入相关的 API 服务 +public class IailabOperateLogRpcAutoConfiguration { +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/package-info.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/package-info.java new file mode 100644 index 0000000..80abe28 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/package-info.java @@ -0,0 +1,4 @@ +/** + * 占位,无特殊作用 + */ +package com.iailab.framework.operatelog.core; \ No newline at end of file diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/service/LogRecordServiceImpl.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/service/LogRecordServiceImpl.java new file mode 100644 index 0000000..0e95b34 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/core/service/LogRecordServiceImpl.java @@ -0,0 +1,87 @@ +package com.iailab.framework.operatelog.core.service; + +import com.iailab.framework.common.util.monitor.TracerUtils; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import com.iailab.module.system.api.logger.OperateLogApi; +import com.iailab.module.system.api.logger.dto.OperateLogCreateReqDTO; +import com.mzt.logapi.beans.LogRecord; +import com.mzt.logapi.service.ILogRecordService; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import java.util.List; + +/** + * 操作日志 ILogRecordService 实现类 + * + * 基于 {@link OperateLogApi} 实现,记录操作日志 + * + * @author HUIHUI + */ +@Slf4j +public class LogRecordServiceImpl implements ILogRecordService { + + @Resource + private OperateLogApi operateLogApi; + + @Override + public void record(LogRecord logRecord) { + // 1. 补全通用字段 + OperateLogCreateReqDTO reqDTO = new OperateLogCreateReqDTO(); + reqDTO.setTraceId(TracerUtils.getTraceId()); + // 补充用户信息 + fillUserFields(reqDTO); + // 补全模块信息 + fillModuleFields(reqDTO, logRecord); + // 补全请求信息 + fillRequestFields(reqDTO); + + // 2. 异步记录日志 + operateLogApi.createOperateLog(reqDTO); + } + + private static void fillUserFields(OperateLogCreateReqDTO reqDTO) { + // 使用 SecurityFrameworkUtils。因为要考虑,rpc、mq、job,它其实不是 web; + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null) { + return; + } + reqDTO.setUserId(loginUser.getId()); + reqDTO.setUserType(loginUser.getUserType()); + } + + public static void fillModuleFields(OperateLogCreateReqDTO reqDTO, LogRecord logRecord) { + reqDTO.setType(logRecord.getType()); // 大模块类型,例如:CRM 客户 + reqDTO.setSubType(logRecord.getSubType());// 操作名称,例如:转移客户 + reqDTO.setBizId(Long.parseLong(logRecord.getBizNo())); // 业务编号,例如:客户编号 + reqDTO.setAction(logRecord.getAction());// 操作内容,例如:修改编号为 1 的用户信息,将性别从男改成女,将姓名从平台改成源码。 + reqDTO.setExtra(logRecord.getExtra()); // 拓展字段,有些复杂的业务,需要记录一些字段 ( JSON 格式 ),例如说,记录订单编号,{ orderId: "1"} + } + + private static void fillRequestFields(OperateLogCreateReqDTO reqDTO) { + // 获得 Request 对象 + HttpServletRequest request = ServletUtils.getRequest(); + if (request == null) { + return; + } + // 补全请求信息 + reqDTO.setRequestMethod(request.getMethod()); + reqDTO.setRequestUrl(request.getRequestURI()); + reqDTO.setUserIp(ServletUtils.getClientIP(request)); + reqDTO.setUserAgent(ServletUtils.getUserAgent(request)); + } + + @Override + public List<LogRecord> queryLog(String bizNo, String type) { + throw new UnsupportedOperationException("使用 OperateLogApi 进行操作日志的查询"); + } + + @Override + public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) { + throw new UnsupportedOperationException("使用 OperateLogApi 进行操作日志的查询"); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/package-info.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/package-info.java new file mode 100644 index 0000000..9df12ab --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/operatelog/package-info.java @@ -0,0 +1,7 @@ +/** + * 基于 mzt-log 框架 + * 实现操作日志功能 + * + * @author HUIHUI + */ +package com.iailab.framework.operatelog; diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/AuthorizeRequestsCustomizer.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/AuthorizeRequestsCustomizer.java new file mode 100644 index 0000000..063c85e --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/AuthorizeRequestsCustomizer.java @@ -0,0 +1,37 @@ +package com.iailab.framework.security.config; + +import com.iailab.framework.web.config.WebProperties; +import org.springframework.core.Ordered; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer; + +import javax.annotation.Resource; + +/** + * 自定义的 URL 的安全配置 + * 目的:每个 Maven Module 可以自定义规则! + * + * @author iailab + */ +public abstract class AuthorizeRequestsCustomizer + implements Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry>, Ordered { + + @Resource + private WebProperties webProperties; + + protected String buildAdminApi(String url) { + return webProperties.getAdminApi().getPrefix() + url; + } + + protected String buildAppApi(String url) { + return webProperties.getAppApi().getPrefix() + url; + } + + @Override + public int getOrder() { + return 0; + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityAutoConfiguration.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityAutoConfiguration.java new file mode 100644 index 0000000..4ab66ee --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityAutoConfiguration.java @@ -0,0 +1,118 @@ +package com.iailab.framework.security.config; + +import cn.hutool.extra.spring.SpringUtil; +import com.iailab.framework.security.core.aop.PreAuthenticatedAspect; +import com.iailab.framework.security.core.context.TransmittableThreadLocalSecurityContextHolderStrategy; +import com.iailab.framework.security.core.filter.TokenAuthenticationFilter; +import com.iailab.framework.security.core.handler.AccessDeniedHandlerImpl; +import com.iailab.framework.security.core.handler.AuthenticationEntryPointImpl; +import com.iailab.framework.security.core.service.SecurityFrameworkService; +import com.iailab.framework.security.core.service.SecurityFrameworkServiceImpl; +import com.iailab.framework.web.core.handler.GlobalExceptionHandler; +import com.iailab.module.system.api.oauth2.OAuth2TokenApi; +import com.iailab.module.system.api.permission.PermissionApi; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.MethodInvokingFactoryBean; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; + +import javax.annotation.Resource; + +/** + * Spring Security 自动配置类,主要用于相关组件的配置 + * + * 注意,不能和 {@link IailabWebSecurityConfigurerAdapter} 用一个,原因是会导致初始化报错。 + * 参见 https://stackoverflow.com/questions/53847050/spring-boot-delegatebuilder-cannot-be-null-on-autowiring-authenticationmanager 文档。 + * + * @author iailab + */ +@AutoConfiguration +@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置,避免一键改包后,org.* 基础包无法生效 +@EnableConfigurationProperties(SecurityProperties.class) +public class IailabSecurityAutoConfiguration { + + @Resource + private SecurityProperties securityProperties; + + /** + * 处理用户未登录拦截的切面的 Bean + */ + @Bean + public PreAuthenticatedAspect preAuthenticatedAspect() { + return new PreAuthenticatedAspect(); + } + + /** + * 认证失败处理类 Bean + */ + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new AuthenticationEntryPointImpl(); + } + + /** + * 权限不够处理器 Bean + */ + @Bean + public AccessDeniedHandler accessDeniedHandler() { + return new AccessDeniedHandlerImpl(); + } + + /** + * Spring Security 加密器 + * 考虑到安全性,这里采用 BCryptPasswordEncoder 加密器 + * + * @see <a href="http://stackabuse.com/password-encoding-with-spring-security/">Password Encoding with Spring Security</a> + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(securityProperties.getPasswordEncoderLength()); + } + + /** + * Token 认证过滤器 Bean + */ + @Bean + public TokenAuthenticationFilter authenticationTokenFilter(GlobalExceptionHandler globalExceptionHandler, + OAuth2TokenApi oauth2TokenApi) { + try { + OAuth2TokenApi oAuth2TokenApi = SpringUtil.getBean("aAuth2TokenApiImpl", OAuth2TokenApi.class); + if (oAuth2TokenApi != null) { + oauth2TokenApi = oAuth2TokenApi; + } + } catch (Exception ignored) {} + return new TokenAuthenticationFilter(securityProperties, globalExceptionHandler, oauth2TokenApi); + } + + @Bean("ss") // 使用 Spring Security 的缩写,方便使用 + public SecurityFrameworkService securityFrameworkService(PermissionApi permissionApi) { + try { + PermissionApi permissionApiImpl = SpringUtil.getBean("permissionApiImpl", PermissionApi.class); + if (permissionApiImpl != null) { + permissionApi = permissionApiImpl; + } + } catch (Exception ignored) {} + return new SecurityFrameworkServiceImpl(permissionApi); + } + + /** + * 声明调用 {@link SecurityContextHolder#setStrategyName(String)} 方法, + * 设置使用 {@link TransmittableThreadLocalSecurityContextHolderStrategy} 作为 Security 的上下文策略 + */ + @Bean + public MethodInvokingFactoryBean securityContextHolderMethodInvokingFactoryBean() { + MethodInvokingFactoryBean methodInvokingFactoryBean = new MethodInvokingFactoryBean(); + methodInvokingFactoryBean.setTargetClass(SecurityContextHolder.class); + methodInvokingFactoryBean.setTargetMethod("setStrategyName"); + methodInvokingFactoryBean.setArguments(TransmittableThreadLocalSecurityContextHolderStrategy.class.getName()); + return methodInvokingFactoryBean; + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityRpcAutoConfiguration.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityRpcAutoConfiguration.java new file mode 100644 index 0000000..c707419 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabSecurityRpcAutoConfiguration.java @@ -0,0 +1,25 @@ +package com.iailab.framework.security.config; + +import com.iailab.framework.security.core.rpc.LoginUserRequestInterceptor; +import com.iailab.module.system.api.oauth2.OAuth2TokenApi; +import com.iailab.module.system.api.permission.PermissionApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Bean; + +/** + * Security 使用到 Feign 的配置项 + * + * @author iailab + */ +@AutoConfiguration +@EnableFeignClients(clients = {OAuth2TokenApi.class, // 主要是引入相关的 API 服务 + PermissionApi.class}) +public class IailabSecurityRpcAutoConfiguration { + + @Bean + public LoginUserRequestInterceptor loginUserRequestInterceptor() { + return new LoginUserRequestInterceptor(); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabWebSecurityConfigurerAdapter.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabWebSecurityConfigurerAdapter.java new file mode 100644 index 0000000..4813ecf --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/IailabWebSecurityConfigurerAdapter.java @@ -0,0 +1,217 @@ +package com.iailab.framework.security.config; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.security.core.filter.TokenAuthenticationFilter; +import com.iailab.framework.web.config.WebProperties; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPattern; + +import javax.annotation.Resource; +import javax.annotation.security.PermitAll; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.iailab.framework.common.util.collection.CollectionUtils.convertList; + +/** + * 自定义的 Spring Security 配置适配器实现 + * + * @author iailab + */ +@AutoConfiguration +@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置,避免一键改包后,org.* 基础包无法生效 +@EnableMethodSecurity(securedEnabled = true) +public class IailabWebSecurityConfigurerAdapter { + + @Resource + private WebProperties webProperties; + @Resource + private SecurityProperties securityProperties; + + /** + * 认证失败处理类 Bean + */ + @Resource + private AuthenticationEntryPoint authenticationEntryPoint; + /** + * 权限不够处理器 Bean + */ + @Resource + private AccessDeniedHandler accessDeniedHandler; + /** + * Token 认证过滤器 Bean + */ + @Resource + private TokenAuthenticationFilter authenticationTokenFilter; + + /** + * 自定义的权限映射 Bean 们 + * + * @see #filterChain(HttpSecurity) + */ + @Resource + private List<AuthorizeRequestsCustomizer> authorizeRequestsCustomizers; + + @Resource + private ApplicationContext applicationContext; + + /** + * 由于 Spring Security 创建 AuthenticationManager 对象时,没声明 @Bean 注解,导致无法被注入 + * 通过覆写父类的该方法,添加 @Bean 注解,解决该问题 + */ + @Bean + public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + /** + * 配置 URL 的安全配置 + * + * anyRequest | 匹配所有请求路径 + * access | SpringEl表达式结果为true时可以访问 + * anonymous | 匿名可以访问 + * denyAll | 用户不能访问 + * fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录) + * hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问 + * hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问 + * hasAuthority | 如果有参数,参数表示权限,则其权限可以访问 + * hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问 + * hasRole | 如果有参数,参数表示角色,则其角色可以访问 + * permitAll | 用户可以任意访问 + * rememberMe | 允许通过remember-me登录的用户访问 + * authenticated | 用户登录后可访问 + */ + @Bean + protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { + // 登出 + httpSecurity + // 开启跨域 + .cors(Customizer.withDefaults()) + // CSRF 禁用,因为不使用 Session + .csrf(AbstractHttpConfigurer::disable) + // 基于 token 机制,所以不需要 Session + .sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) + // 一堆自定义的 Spring Security 处理器 + .exceptionHandling(c -> c.authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)); + // 登录、登录暂时不使用 Spring Security 的拓展点,主要考虑一方面拓展多用户、多种登录方式相对复杂,一方面用户的学习成本较高 + + // 获得 @PermitAll 带来的 URL 列表,免登录 + Multimap<HttpMethod, String> permitAllUrls = getPermitAllUrlsFromAnnotations(); + // 设置每个请求的权限 + httpSecurity + // ①:全局共享规则 + .authorizeHttpRequests(c -> c + // 1.1 静态资源,可匿名访问 + .requestMatchers(HttpMethod.GET, "/*.html", "/*.html", "/*.css", "/*.js").permitAll() + // 1.2 设置 @PermitAll 无需认证 + .requestMatchers(HttpMethod.GET, permitAllUrls.get(HttpMethod.GET).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.POST, permitAllUrls.get(HttpMethod.POST).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.PUT, permitAllUrls.get(HttpMethod.PUT).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.DELETE, permitAllUrls.get(HttpMethod.DELETE).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.HEAD, permitAllUrls.get(HttpMethod.HEAD).toArray(new String[0])).permitAll() + .requestMatchers(HttpMethod.PATCH, permitAllUrls.get(HttpMethod.PATCH).toArray(new String[0])).permitAll() + // 1.3 基于 yudao.security.permit-all-urls 无需认证 + .requestMatchers(securityProperties.getPermitAllUrls().toArray(new String[0])).permitAll() + ) + // ②:每个项目的自定义规则 + .authorizeHttpRequests(c -> authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(c))) + // ③:兜底规则,必须认证 + .authorizeHttpRequests(c -> c.anyRequest().authenticated()); + + // 添加 Token Filter + httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); + return httpSecurity.build(); + } + + private String buildAppApi(String url) { + return webProperties.getAppApi().getPrefix() + url; + } + + private Multimap<HttpMethod, String> getPermitAllUrlsFromAnnotations() { + Multimap<HttpMethod, String> result = HashMultimap.create(); + // 获得接口对应的 HandlerMethod 集合 + RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) + applicationContext.getBean("requestMappingHandlerMapping"); + Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods(); + // 获得有 @PermitAll 注解的接口 + for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethodMap.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!handlerMethod.hasMethodAnnotation(PermitAll.class)) { + continue; + } + Set<String> urls = new HashSet<>(); + if (entry.getKey().getPatternsCondition() != null) { + urls.addAll(entry.getKey().getPatternsCondition().getPatterns()); + } + if (entry.getKey().getPathPatternsCondition() != null) { + urls.addAll(convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString)); + } + if (urls.isEmpty()) { + continue; + } + + // 特殊:使用 @RequestMapping 注解,并且未写 method 属性,此时认为都需要免登录 + Set<RequestMethod> methods = entry.getKey().getMethodsCondition().getMethods(); + if (CollUtil.isEmpty(methods)) { + result.putAll(HttpMethod.GET, urls); + result.putAll(HttpMethod.POST, urls); + result.putAll(HttpMethod.PUT, urls); + result.putAll(HttpMethod.DELETE, urls); + result.putAll(HttpMethod.HEAD, urls); + result.putAll(HttpMethod.PATCH, urls); + continue; + } + // 根据请求方法,添加到 result 结果 + entry.getKey().getMethodsCondition().getMethods().forEach(requestMethod -> { + switch (requestMethod) { + case GET: + result.putAll(HttpMethod.GET, urls); + break; + case POST: + result.putAll(HttpMethod.POST, urls); + break; + case PUT: + result.putAll(HttpMethod.PUT, urls); + break; + case DELETE: + result.putAll(HttpMethod.DELETE, urls); + break; + case HEAD: + result.putAll(HttpMethod.HEAD, urls); + break; + case PATCH: + result.putAll(HttpMethod.PATCH, urls); + break; + } + }); + } + return result; + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/SecurityProperties.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/SecurityProperties.java new file mode 100644 index 0000000..57a6b47 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/config/SecurityProperties.java @@ -0,0 +1,51 @@ +package com.iailab.framework.security.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Collections; +import java.util.List; + +@ConfigurationProperties(prefix = "iailab.security") +@Validated +@Data +public class SecurityProperties { + + /** + * HTTP 请求时,访问令牌的请求 Header + */ + @NotEmpty(message = "Token Header 不能为空") + private String tokenHeader = "Authorization"; + /** + * HTTP 请求时,访问令牌的请求参数 + * + * 初始目的:解决 WebSocket 无法通过 header 传参,只能通过 token 参数拼接 + */ + @NotEmpty(message = "Token Parameter 不能为空") + private String tokenParameter = "token"; + + /** + * mock 模式的开关 + */ + @NotNull(message = "mock 模式的开关不能为空") + private Boolean mockEnable = false; + /** + * mock 模式的密钥 + * 一定要配置密钥,保证安全性 + */ + @NotEmpty(message = "mock 模式的密钥不能为空") // 这里设置了一个默认值,因为实际上只有 mockEnable 为 true 时才需要配置。 + private String mockSecret = "test"; + + /** + * 免登录的 URL 列表 + */ + private List<String> permitAllUrls = Collections.emptyList(); + + /** + * PasswordEncoder 加密复杂度,越高开销越大 + */ + private Integer passwordEncoderLength = 4; +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/LoginUser.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/LoginUser.java new file mode 100644 index 0000000..989ef9a --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/LoginUser.java @@ -0,0 +1,75 @@ +package com.iailab.framework.security.core; + +import cn.hutool.core.map.MapUtil; +import com.iailab.framework.common.enums.UserTypeEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 登录用户信息 + * + * @author iailab + */ +@Data +public class LoginUser { + + public static final String INFO_KEY_NICKNAME = "nickname"; + public static final String INFO_KEY_DEPT_ID = "deptId"; + + /** + * 用户编号 + */ + private Long id; + /** + * 用户类型 + * + * 关联 {@link UserTypeEnum} + */ + private Integer userType; + /** + * 额外的用户信息 + */ + private Map<String, String> info; + /** + * 租户编号 + */ + private Long tenantId; + /** + * 授权范围 + */ + private List<String> scopes; + /** + * 过期时间 + */ + private LocalDateTime expiresTime; + /** + * 访问令牌 + */ + private String accessToken; + + // ========== 上下文 ========== + /** + * 上下文字段,不进行持久化 + * + * 1. 用于基于 LoginUser 维度的临时缓存 + */ + @JsonIgnore + private Map<String, Object> context; + + public void setContext(String key, Object value) { + if (context == null) { + context = new HashMap<>(); + } + context.put(key, value); + } + + public <T> T getContext(String key, Class<T> type) { + return MapUtil.get(context, key, type); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/annotations/PreAuthenticated.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/annotations/PreAuthenticated.java new file mode 100644 index 0000000..f2f6c6c --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/annotations/PreAuthenticated.java @@ -0,0 +1,17 @@ +package com.iailab.framework.security.core.annotations; + +import java.lang.annotation.*; + +/** + * 声明用户需要登录 + * + * 为什么不使用 {@link org.springframework.security.access.prepost.PreAuthorize} 注解,原因是不通过时,抛出的是认证不通过,而不是未登录 + * + * @author iailab + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Inherited +@Documented +public @interface PreAuthenticated { +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/aop/PreAuthenticatedAspect.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/aop/PreAuthenticatedAspect.java new file mode 100644 index 0000000..806207a --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/aop/PreAuthenticatedAspect.java @@ -0,0 +1,25 @@ +package com.iailab.framework.security.core.aop; + +import com.iailab.framework.security.core.annotations.PreAuthenticated; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; + +import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; +import static com.iailab.framework.common.exception.util.ServiceExceptionUtil.exception; + +@Aspect +@Slf4j +public class PreAuthenticatedAspect { + + @Around("@annotation(preAuthenticated)") + public Object around(ProceedingJoinPoint joinPoint, PreAuthenticated preAuthenticated) throws Throwable { + if (SecurityFrameworkUtils.getLoginUser() == null) { + throw exception(UNAUTHORIZED); + } + return joinPoint.proceed(); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java new file mode 100644 index 0000000..5eefcfe --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/context/TransmittableThreadLocalSecurityContextHolderStrategy.java @@ -0,0 +1,48 @@ +package com.iailab.framework.security.core.context; + +import com.alibaba.ttl.TransmittableThreadLocal; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.util.Assert; + +/** + * 基于 TransmittableThreadLocal 实现的 Security Context 持有者策略 + * 目的是,避免 @Async 等异步执行时,原生 ThreadLocal 的丢失问题 + * + * @author iailab + */ +public class TransmittableThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy { + + /** + * 使用 TransmittableThreadLocal 作为上下文 + */ + private static final ThreadLocal<SecurityContext> CONTEXT_HOLDER = new TransmittableThreadLocal<>(); + + @Override + public void clearContext() { + CONTEXT_HOLDER.remove(); + } + + @Override + public SecurityContext getContext() { + SecurityContext ctx = CONTEXT_HOLDER.get(); + if (ctx == null) { + ctx = createEmptyContext(); + CONTEXT_HOLDER.set(ctx); + } + return ctx; + } + + @Override + public void setContext(SecurityContext context) { + Assert.notNull(context, "Only non-null SecurityContext instances are permitted"); + CONTEXT_HOLDER.set(context); + } + + @Override + public SecurityContext createEmptyContext() { + return new SecurityContextImpl(); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/filter/TokenAuthenticationFilter.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..161e12f --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/filter/TokenAuthenticationFilter.java @@ -0,0 +1,147 @@ +package com.iailab.framework.security.core.filter; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.security.config.SecurityProperties; +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import com.iailab.framework.web.core.handler.GlobalExceptionHandler; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import com.iailab.module.system.api.oauth2.OAuth2TokenApi; +import com.iailab.module.system.api.oauth2.dto.OAuth2AccessTokenCheckRespDTO; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +/** + * Token 过滤器,验证 token 的有效性 + * 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文 + * + * @author iailab + */ +@RequiredArgsConstructor +@Slf4j +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final SecurityProperties securityProperties; + + private final GlobalExceptionHandler globalExceptionHandler; + + private final OAuth2TokenApi oauth2TokenApi; + + @Override + @SuppressWarnings("NullableProblems") + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + // 情况一,基于 header[login-user] 获得用户,例如说来自 Gateway 或者其它服务透传 + LoginUser loginUser = buildLoginUserByHeader(request); + + // 情况二,基于 Token 获得用户 + // 注意,这里主要满足直接使用 Nginx 直接转发到 Spring Cloud 服务的场景。 + if (loginUser == null) { + String token = SecurityFrameworkUtils.obtainAuthorization(request, + securityProperties.getTokenHeader(), securityProperties.getTokenParameter()); + if (StrUtil.isNotEmpty(token)) { + Integer userType = WebFrameworkUtils.getLoginUserType(request); + try { + // 1.1 基于 token 构建登录用户 + loginUser = buildLoginUserByToken(token, userType); + // 1.2 模拟 Login 功能,方便日常开发调试 + if (loginUser == null) { + loginUser = mockLoginUser(request, token, userType); + } + } catch (Throwable ex) { + CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex); + ServletUtils.writeJSON(response, result); + return; + } + } + } + + // 设置当前用户 + if (loginUser != null) { + SecurityFrameworkUtils.setLoginUser(loginUser, request); + } + // 继续过滤链 + chain.doFilter(request, response); + } + + private LoginUser buildLoginUserByToken(String token, Integer userType) { + try { + // 校验访问令牌 + OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token).getCheckedData(); + if (accessToken == null) { + return null; + } + // 用户类型不匹配,无权限 + // 注意:只有 /admin-api/* 和 /app-api/* 有 userType,才需要比对用户类型 + // 类似 WebSocket 的 /ws/* 连接地址,是不需要比对用户类型的 + if (userType != null + && ObjectUtil.notEqual(accessToken.getUserType(), userType)) { + throw new AccessDeniedException("错误的用户类型"); + } + // 构建登录用户 + return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType()) + .setInfo(accessToken.getUserInfo()) // 额外的用户信息 + .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes()) + .setExpiresTime(accessToken.getExpiresTime()); + } catch (ServiceException serviceException) { + // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可 + return null; + } + } + + /** + * 模拟登录用户,方便日常开发调试 + * + * 注意,在线上环境下,一定要关闭该功能!!! + * + * @param request 请求 + * @param token 模拟的 token,格式为 {@link SecurityProperties#getMockSecret()} + 用户编号 + * @param userType 用户类型 + * @return 模拟的 LoginUser + */ + private LoginUser mockLoginUser(HttpServletRequest request, String token, Integer userType) { + if (!securityProperties.getMockEnable()) { + return null; + } + // 必须以 mockSecret 开头 + if (!token.startsWith(securityProperties.getMockSecret())) { + return null; + } + // 构建模拟用户 + Long userId = Long.valueOf(token.substring(securityProperties.getMockSecret().length())); + return new LoginUser().setId(userId).setUserType(userType) + .setTenantId(WebFrameworkUtils.getTenantId(request)); + } + + @SneakyThrows + private LoginUser buildLoginUserByHeader(HttpServletRequest request) { + String loginUserStr = request.getHeader(SecurityFrameworkUtils.LOGIN_USER_HEADER); + if (StrUtil.isEmpty(loginUserStr)) { + return null; + } + try { + loginUserStr = URLDecoder.decode(loginUserStr, StandardCharsets.UTF_8.name()); // 解码,解决中文乱码问题 + return JsonUtils.parseObject(loginUserStr, LoginUser.class); + } catch (Exception ex) { + log.error("[buildLoginUserByHeader][解析 LoginUser({}) 发生异常]", loginUserStr, ex); ; + throw ex; + } + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AccessDeniedHandlerImpl.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AccessDeniedHandlerImpl.java new file mode 100644 index 0000000..0573f50 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AccessDeniedHandlerImpl.java @@ -0,0 +1,43 @@ +package com.iailab.framework.security.core.handler; + +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import com.iailab.framework.common.util.servlet.ServletUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.stereotype.Component; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN; +import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; + +/** + * 访问一个需要认证的 URL 资源,已经认证(登录)但是没有权限的情况下,返回 {@link GlobalErrorCodeConstants#FORBIDDEN} 错误码。 + * + * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#handleAccessDeniedException(HttpServletRequest, HttpServletResponse, FilterChain, AccessDeniedException)} 方法,调用当前类 + * + * @author iailab + */ +@Slf4j +@SuppressWarnings("JavadocReference") +public class AccessDeniedHandlerImpl implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) + throws IOException, ServletException { + // 打印 warn 的原因是,不定期合并 warn,看看有没恶意破坏 + log.warn("[commence][访问 URL({}) 时,用户({}) 权限不够]", request.getRequestURI(), + SecurityFrameworkUtils.getLoginUserId(), e); + // 返回 403 + ServletUtils.writeJSON(response, CommonResult.error(FORBIDDEN)); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AuthenticationEntryPointImpl.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..dc769b7 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/handler/AuthenticationEntryPointImpl.java @@ -0,0 +1,35 @@ +package com.iailab.framework.security.core.handler; + +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.servlet.ServletUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.ExceptionTranslationFilter; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED; + +/** + * 访问一个需要认证的 URL 资源,但是此时自己尚未认证(登录)的情况下,返回 {@link GlobalErrorCodeConstants#UNAUTHORIZED} 错误码,从而使前端重定向到登录页 + * + * 补充:Spring Security 通过 {@link ExceptionTranslationFilter#sendStartAuthentication(HttpServletRequest, HttpServletResponse, FilterChain, AuthenticationException)} 方法,调用当前类 + * + * @author ruoyi + */ +@Slf4j +@SuppressWarnings("JavadocReference") // 忽略文档引用报错 +public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) { + log.debug("[commence][访问 URL({}) 时,没有登录]", request.getRequestURI(), e); + // 返回 401 + ServletUtils.writeJSON(response, CommonResult.error(UNAUTHORIZED)); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/rpc/LoginUserRequestInterceptor.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/rpc/LoginUserRequestInterceptor.java new file mode 100644 index 0000000..4f89c15 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/rpc/LoginUserRequestInterceptor.java @@ -0,0 +1,39 @@ +package com.iailab.framework.security.core.rpc; + +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import feign.RequestInterceptor; +import feign.RequestTemplate; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * LoginUser 的 RequestInterceptor 实现类:Feign 请求时,将 {@link LoginUser} 设置到 header 中,继续透传给被调用的服务 + * + * @author iailab + */ +@Slf4j +public class LoginUserRequestInterceptor implements RequestInterceptor { + + @Override + @SneakyThrows + public void apply(RequestTemplate requestTemplate) { + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user == null) { + return; + } + try { + String userStr = JsonUtils.toJsonString(user); + userStr = URLEncoder.encode(userStr, StandardCharsets.UTF_8.name()); // 编码,避免中文乱码 + requestTemplate.header(SecurityFrameworkUtils.LOGIN_USER_HEADER, userStr); + } catch (Exception ex) { + log.error("[apply][序列化 LoginUser({}) 发生异常]", user, ex); + throw ex; + } + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkService.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkService.java new file mode 100644 index 0000000..a32f9f5 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkService.java @@ -0,0 +1,59 @@ +package com.iailab.framework.security.core.service; + +/** + * Security 框架 Service 接口,定义权限相关的校验操作 + * + * @author iailab + */ +public interface SecurityFrameworkService { + + /** + * 判断是否有权限 + * + * @param permission 权限 + * @return 是否 + */ + boolean hasPermission(String permission); + + /** + * 判断是否有权限,任一一个即可 + * + * @param permissions 权限 + * @return 是否 + */ + boolean hasAnyPermissions(String... permissions); + + /** + * 判断是否有角色 + * + * 注意,角色使用的是 SysRoleDO 的 code 标识 + * + * @param role 角色 + * @return 是否 + */ + boolean hasRole(String role); + + /** + * 判断是否有角色,任一一个即可 + * + * @param roles 角色数组 + * @return 是否 + */ + boolean hasAnyRoles(String... roles); + + /** + * 判断是否有授权 + * + * @param scope 授权 + * @return 是否 + */ + boolean hasScope(String scope); + + /** + * 判断是否有授权范围,任一一个即可 + * + * @param scope 授权范围数组 + * @return 是否 + */ + boolean hasAnyScopes(String... scope); +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkServiceImpl.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkServiceImpl.java new file mode 100644 index 0000000..c4e4ec1 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/service/SecurityFrameworkServiceImpl.java @@ -0,0 +1,103 @@ +package com.iailab.framework.security.core.service; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.common.core.KeyValue; +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import com.iailab.module.system.api.permission.PermissionApi; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; + +import static com.iailab.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache; +import static com.iailab.framework.common.util.cache.CacheUtils.buildCache; +import static com.iailab.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +/** + * 默认的 {@link SecurityFrameworkService} 实现类 + * + * @author iailab + */ +@AllArgsConstructor +public class SecurityFrameworkServiceImpl implements SecurityFrameworkService { + + private final PermissionApi permissionApi; + + /** + * 针对 {@link #hasAnyRoles(String...)} 的缓存 + */ + private final LoadingCache<KeyValue<Long, List<String>>, Boolean> hasAnyRolesCache = buildCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<KeyValue<Long, List<String>>, Boolean>() { + + @Override + public Boolean load(KeyValue<Long, List<String>> key) { + return permissionApi.hasAnyRoles(key.getKey(), key.getValue().toArray(new String[0])).getCheckedData(); + } + + }); + + /** + * 针对 {@link #hasAnyPermissions(String...)} 的缓存 + */ + private final LoadingCache<KeyValue<Long, List<String>>, Boolean> hasAnyPermissionsCache = buildCache( + Duration.ofMinutes(1L), // 过期时间 1 分钟 + new CacheLoader<KeyValue<Long, List<String>>, Boolean>() { + + @Override + public Boolean load(KeyValue<Long, List<String>> key) { + return permissionApi.hasAnyPermissions(key.getKey(), key.getValue().toArray(new String[0])).getCheckedData(); + } + + }); + + @Override + public boolean hasPermission(String permission) { + return hasAnyPermissions(permission); + } + + @Override + @SneakyThrows + public boolean hasAnyPermissions(String... permissions) { + Long userId = getLoginUserId(); + if (userId == null) { + return false; + } + return hasAnyPermissionsCache.get(new KeyValue<>(userId, Arrays.asList(permissions))); + } + + @Override + public boolean hasRole(String role) { + return hasAnyRoles(role); + } + + @Override + @SneakyThrows + public boolean hasAnyRoles(String... roles) { + Long userId = getLoginUserId(); + if (userId == null) { + return false; + } + return hasAnyRolesCache.get(new KeyValue<>(userId, Arrays.asList(roles))); + } + + @Override + public boolean hasScope(String scope) { + return hasAnyScopes(scope); + } + + @Override + public boolean hasAnyScopes(String... scope) { + LoginUser user = SecurityFrameworkUtils.getLoginUser(); + if (user == null) { + return false; + } + return CollUtil.containsAny(user.getScopes(), Arrays.asList(scope)); + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/util/SecurityFrameworkUtils.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/util/SecurityFrameworkUtils.java new file mode 100644 index 0000000..c940895 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/core/util/SecurityFrameworkUtils.java @@ -0,0 +1,142 @@ +package com.iailab.framework.security.core.util; + +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; + +import javax.servlet.http.HttpServletRequest; +import java.util.Collections; + +/** + * 安全服务工具类 + * + * @author iailab + */ +public class SecurityFrameworkUtils { + + /** + * HEADER 认证头 value 的前缀 + */ + public static final String AUTHORIZATION_BEARER = "Bearer"; + + public static final String LOGIN_USER_HEADER = "login-user"; + + private SecurityFrameworkUtils() {} + + /** + * 从请求中,获得认证 Token + * + * @param request 请求 + * @param headerName 认证 Token 对应的 Header 名字 + * @param parameterName 认证 Token 对应的 Parameter 名字 + * @return 认证 Token + */ + public static String obtainAuthorization(HttpServletRequest request, + String headerName, String parameterName) { + // 1. 获得 Token。优先级:Header > Parameter + String token = request.getHeader(headerName); + if (StrUtil.isEmpty(token)) { + token = request.getParameter(parameterName); + } + if (!StringUtils.hasText(token)) { + return null; + } + // 2. 去除 Token 中带的 Bearer + int index = token.indexOf(AUTHORIZATION_BEARER + " "); + return index >= 0 ? token.substring(index + 7).trim() : token; + } + + /** + * 获得当前认证信息 + * + * @return 认证信息 + */ + public static Authentication getAuthentication() { + SecurityContext context = SecurityContextHolder.getContext(); + if (context == null) { + return null; + } + return context.getAuthentication(); + } + + /** + * 获取当前用户 + * + * @return 当前用户 + */ + @Nullable + public static LoginUser getLoginUser() { + Authentication authentication = getAuthentication(); + if (authentication == null) { + return null; + } + return authentication.getPrincipal() instanceof LoginUser ? (LoginUser) authentication.getPrincipal() : null; + } + + /** + * 获得当前用户的编号,从上下文中 + * + * @return 用户编号 + */ + @Nullable + public static Long getLoginUserId() { + LoginUser loginUser = getLoginUser(); + return loginUser != null ? loginUser.getId() : null; + } + + /** + * 获得当前用户的昵称,从上下文中 + * + * @return 昵称 + */ + @Nullable + public static String getLoginUserNickname() { + LoginUser loginUser = getLoginUser(); + return loginUser != null ? MapUtil.getStr(loginUser.getInfo(), LoginUser.INFO_KEY_NICKNAME) : null; + } + + /** + * 获得当前用户的部门编号,从上下文中 + * + * @return 部门编号 + */ + @Nullable + public static Long getLoginUserDeptId() { + LoginUser loginUser = getLoginUser(); + return loginUser != null ? MapUtil.getLong(loginUser.getInfo(), LoginUser.INFO_KEY_DEPT_ID) : null; + } + + /** + * 设置当前用户 + * + * @param loginUser 登录用户 + * @param request 请求 + */ + public static void setLoginUser(LoginUser loginUser, HttpServletRequest request) { + // 创建 Authentication,并设置到上下文 + Authentication authentication = buildAuthentication(loginUser, request); + SecurityContextHolder.getContext().setAuthentication(authentication); + + // 额外设置到 request 中,用于 ApiAccessLogFilter 可以获取到用户编号; + // 原因是,Spring Security 的 Filter 在 ApiAccessLogFilter 后面,在它记录访问日志时,线上上下文已经没有用户编号等信息 + WebFrameworkUtils.setLoginUserId(request, loginUser.getId()); + WebFrameworkUtils.setLoginUserType(request, loginUser.getUserType()); + } + + private static Authentication buildAuthentication(LoginUser loginUser, HttpServletRequest request) { + // 创建 UsernamePasswordAuthenticationToken 对象 + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + loginUser, null, Collections.emptyList()); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + return authenticationToken; + } + +} diff --git a/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/package-info.java b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/package-info.java new file mode 100644 index 0000000..aac86f2 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/java/com/iailab/framework/security/package-info.java @@ -0,0 +1,7 @@ +/** + * 基于 Spring Security 框架 + * 实现安全认证功能 + * + * @author iailab + */ +package com.iailab.framework.security; diff --git a/iailab-framework/iailab-common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..7af8b2d --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,5 @@ +com.iailab.framework.security.config.IailabSecurityRpcAutoConfiguration +com.iailab.framework.security.config.IailabSecurityAutoConfiguration +com.iailab.framework.security.config.IailabWebSecurityConfigurerAdapter +com.iailab.framework.operatelog.config.IailabOperateLogConfiguration +com.iailab.framework.operatelog.config.IailabOperateLogRpcAutoConfiguration \ No newline at end of file diff --git a/iailab-framework/iailab-common-security/src/main/resources/assembly.xml b/iailab-framework/iailab-common-security/src/main/resources/assembly.xml new file mode 100644 index 0000000..22ad348 --- /dev/null +++ b/iailab-framework/iailab-common-security/src/main/resources/assembly.xml @@ -0,0 +1,50 @@ +<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0 http://maven.apache.org/xsd/assembly-2.1.0.xsd"> +<!-- <id>jar-with-dependencies</id>--> + <formats> + <format>jar</format> + </formats> + <includeBaseDirectory>true</includeBaseDirectory> + + <!--依赖jar包以及项目打包文件存储文件--> + <dependencySets> + <dependencySet> + <!--存储在projectName-assembly-version/lib下--> + <outputDirectory>lib</outputDirectory> + </dependencySet> + </dependencySets> + + <files> + <file> + <fileMode>775</fileMode> + <source>target/${project.build.finalName}.jar</source> + <destName>demo-maven-assembly.jar</destName> + <outputDirectory>./target</outputDirectory> + </file> + </files> + + <fileSets> + <fileSet> + <directory>src/main/resources</directory> + <outputDirectory>./conf</outputDirectory> + <includes> + <include>*.yml</include> + <include>*.properties</include> + </includes> + </fileSet> + <fileSet> + <fileMode>775</fileMode> + <directory>deploy/bin</directory> + <outputDirectory>./bin</outputDirectory> + </fileSet> + <fileSet> + <directory>${basedir}</directory> + <includes> + <include>*.md</include> + </includes> + </fileSet> + + </fileSets> + +</assembly> \ No newline at end of file diff --git a/iailab-framework/iailab-common-test/pom.xml b/iailab-framework/iailab-common-test/pom.xml new file mode 100644 index 0000000..98e1a74 --- /dev/null +++ b/iailab-framework/iailab-common-test/pom.xml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-test</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>测试组件,用于单元测试、集成测试</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- DB 相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-mybatis</artifactId> + </dependency> + + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-redis</artifactId> + </dependency> + + <!-- Test 测试相关 --> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-inline</artifactId> + </dependency> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + </dependency> + + <dependency> + <groupId>com.h2database</groupId> <!-- 单元测试,我们采用 H2 作为数据库 --> + <artifactId>h2</artifactId> + </dependency> + + <dependency> + <groupId>com.github.fppt</groupId> <!-- 单元测试,我们采用内嵌的 Redis 数据库 --> + <artifactId>jedis-mock</artifactId> + </dependency> + + <dependency> + <groupId>uk.co.jemos.podam</groupId> <!-- 单元测试,随机生成 POJO 类 --> + <artifactId>podam</artifactId> + </dependency> + </dependencies> +</project> diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/RedisTestConfiguration.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/RedisTestConfiguration.java new file mode 100644 index 0000000..1070768 --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/RedisTestConfiguration.java @@ -0,0 +1,35 @@ +package com.iailab.framework.test.config; + +import com.github.fppt.jedismock.RedisServer; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +import java.io.IOException; + +/** + * Redis 测试 Configuration,主要实现内嵌 Redis 的启动 + * + * @author iailab + */ +@Configuration(proxyBeanMethods = false) +@Lazy(false) // 禁止延迟加载 +@EnableConfigurationProperties(RedisProperties.class) +public class RedisTestConfiguration { + + /** + * 创建模拟的 Redis Server 服务器 + */ + @Bean + public RedisServer redisServer(RedisProperties properties) throws IOException { + RedisServer redisServer = new RedisServer(properties.getPort()); + // 一次执行多个单元测试时,貌似创建多个 spring 容器,导致不进行 stop。这样,就导致端口被占用,无法启动。。。 + try { + redisServer.start(); + } catch (Exception ignore) {} + return redisServer; + } + +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/SqlInitializationTestConfiguration.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/SqlInitializationTestConfiguration.java new file mode 100644 index 0000000..9703f2c --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/config/SqlInitializationTestConfiguration.java @@ -0,0 +1,52 @@ +package com.iailab.framework.test.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer; +import org.springframework.boot.sql.init.AbstractScriptDatabaseInitializer; +import org.springframework.boot.sql.init.DatabaseInitializationSettings; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +import javax.sql.DataSource; + +/** + * SQL 初始化的测试 Configuration + * + * 为什么不使用 org.springframework.boot.autoconfigure.sql.init.DataSourceInitializationConfiguration 呢? + * 因为我们在单元测试会使用 spring.main.lazy-initialization 为 true,开启延迟加载。此时,会导致 DataSourceInitializationConfiguration 初始化 + * 不过呢,当前类的实现代码,基本是复制 DataSourceInitializationConfiguration 的哈! + * + * @author iailab + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnMissingBean(AbstractScriptDatabaseInitializer.class) +@ConditionalOnSingleCandidate(DataSource.class) +@ConditionalOnClass(name = "org.springframework.jdbc.datasource.init.DatabasePopulator") +@Lazy(value = false) // 禁止延迟加载 +@EnableConfigurationProperties(SqlInitializationProperties.class) +public class SqlInitializationTestConfiguration { + + @Bean + public DataSourceScriptDatabaseInitializer dataSourceScriptDatabaseInitializer(DataSource dataSource, + SqlInitializationProperties initializationProperties) { + DatabaseInitializationSettings settings = createFrom(initializationProperties); + return new DataSourceScriptDatabaseInitializer(dataSource, settings); + } + + static DatabaseInitializationSettings createFrom(SqlInitializationProperties properties) { + DatabaseInitializationSettings settings = new DatabaseInitializationSettings(); + settings.setSchemaLocations(properties.getSchemaLocations()); + settings.setDataLocations(properties.getDataLocations()); + settings.setContinueOnError(properties.isContinueOnError()); + settings.setSeparator(properties.getSeparator()); + settings.setEncoding(properties.getEncoding()); + settings.setMode(properties.getMode()); + return settings; + } + +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbAndRedisUnitTest.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbAndRedisUnitTest.java new file mode 100644 index 0000000..1c4b8d9 --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbAndRedisUnitTest.java @@ -0,0 +1,51 @@ +package com.iailab.framework.test.core.ut; + +import com.iailab.framework.datasource.config.IailabDataSourceAutoConfiguration; +import com.iailab.framework.mybatis.config.IailabMybatisAutoConfiguration; +import com.iailab.framework.redis.config.IailabRedisAutoConfiguration; +import com.iailab.framework.test.config.RedisTestConfiguration; +import com.iailab.framework.test.config.SqlInitializationTestConfiguration; +import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import org.redisson.spring.starter.RedissonAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +/** + * 依赖内存 DB + Redis 的单元测试 + * + * 相比 {@link BaseDbUnitTest} 来说,额外增加了内存 Redis + * + * @author iailab + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbAndRedisUnitTest.Application.class) +@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件 +@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 每个单元测试结束后,清理 DB +public class BaseDbAndRedisUnitTest { + + @Import({ + // DB 配置类 + IailabDataSourceAutoConfiguration.class, // 自己的 DB 配置类 + DataSourceAutoConfiguration.class, // Spring DB 自动配置类 + DataSourceTransactionManagerAutoConfiguration.class, // Spring 事务自动配置类 + DruidDataSourceAutoConfigure.class, // Druid 自动配置类 + SqlInitializationTestConfiguration.class, // SQL 初始化 + // MyBatis 配置类 + IailabMybatisAutoConfiguration.class, // 自己的 MyBatis 配置类 + MybatisPlusAutoConfiguration.class, // MyBatis 的自动配置类 + + // Redis 配置类 + RedisTestConfiguration.class, // Redis 测试配置类,用于启动 RedisServer + IailabRedisAutoConfiguration.class, // 自己的 Redis 配置类 + RedisAutoConfiguration.class, // Spring Redis 自动配置类 + RedissonAutoConfiguration.class, // Redisson 自动配置类 + }) + public static class Application { + } + +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbUnitTest.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbUnitTest.java new file mode 100644 index 0000000..bd8d0f5 --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseDbUnitTest.java @@ -0,0 +1,43 @@ +package com.iailab.framework.test.core.ut; + +import com.iailab.framework.datasource.config.IailabDataSourceAutoConfiguration; +import com.iailab.framework.mybatis.config.IailabMybatisAutoConfiguration; +import com.iailab.framework.test.config.SqlInitializationTestConfiguration; +import com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceAutoConfigure; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +/** + * 依赖内存 DB 的单元测试 + * + * 注意,Service 层同样适用。对于 Service 层的单元测试,我们针对自己模块的 Mapper 走的是 H2 内存数据库,针对别的模块的 Service 走的是 Mock 方法 + * + * @author iailab + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseDbUnitTest.Application.class) +@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件 +@Sql(scripts = "/sql/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // 每个单元测试结束后,清理 DB +public class BaseDbUnitTest { + + @Import({ + // DB 配置类 + IailabDataSourceAutoConfiguration.class, // 自己的 DB 配置类 + DataSourceAutoConfiguration.class, // Spring DB 自动配置类 + DataSourceTransactionManagerAutoConfiguration.class, // Spring 事务自动配置类 + DruidDataSourceAutoConfigure.class, // Druid 自动配置类 + SqlInitializationTestConfiguration.class, // SQL 初始化 + // MyBatis 配置类 + IailabMybatisAutoConfiguration.class, // 自己的 MyBatis 配置类 + MybatisPlusAutoConfiguration.class, // MyBatis 的自动配置类 + MybatisPlusJoinAutoConfiguration.class, // MyBatis 的Join配置类 + }) + public static class Application { + } + +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseMockitoUnitTest.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseMockitoUnitTest.java new file mode 100644 index 0000000..198c6d0 --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseMockitoUnitTest.java @@ -0,0 +1,13 @@ +package com.iailab.framework.test.core.ut; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * 纯 Mockito 的单元测试 + * + * @author iailab + */ +@ExtendWith(MockitoExtension.class) +public class BaseMockitoUnitTest { +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseRedisUnitTest.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseRedisUnitTest.java new file mode 100644 index 0000000..dc1acd9 --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/BaseRedisUnitTest.java @@ -0,0 +1,32 @@ +package com.iailab.framework.test.core.ut; + +import com.iailab.framework.redis.config.IailabRedisAutoConfiguration; +import com.iailab.framework.test.config.RedisTestConfiguration; +import org.redisson.spring.starter.RedissonAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +/** + * 依赖内存 Redis 的单元测试 + * + * 相比 {@link BaseDbUnitTest} 来说,从内存 DB 改成了内存 Redis + * + * @author iailab + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, classes = BaseRedisUnitTest.Application.class) +@ActiveProfiles("unit-test") // 设置使用 application-unit-test 配置文件 +public class BaseRedisUnitTest { + + @Import({ + // Redis 配置类 + RedisTestConfiguration.class, // Redis 测试配置类,用于启动 RedisServer + RedisAutoConfiguration.class, // Spring Redis 自动配置类 + IailabRedisAutoConfiguration.class, // 自己的 Redis 配置类 + RedissonAutoConfiguration.class, // Redisson 自动配置类 + }) + public static class Application { + } + +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/package-info.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/package-info.java new file mode 100644 index 0000000..faa9f02 --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/ut/package-info.java @@ -0,0 +1,4 @@ +/** + * 提供单元测试 Unit Test 的基类 + */ +package com.iailab.framework.test.core.ut; diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/AssertUtils.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/AssertUtils.java new file mode 100644 index 0000000..db91b59 --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/AssertUtils.java @@ -0,0 +1,101 @@ +package com.iailab.framework.test.core.util; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ReflectUtil; +import com.iailab.framework.common.exception.ErrorCode; +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.exception.util.ServiceExceptionUtil; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.function.Executable; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * 单元测试,assert 断言工具类 + * + * @author iailab + */ +public class AssertUtils { + + /** + * 比对两个对象的属性是否一致 + * + * 注意,如果 expected 存在的属性,actual 不存在的时候,会进行忽略 + * + * @param expected 期望对象 + * @param actual 实际对象 + * @param ignoreFields 忽略的属性数组 + */ + public static void assertPojoEquals(Object expected, Object actual, String... ignoreFields) { + Field[] expectedFields = ReflectUtil.getFields(expected.getClass()); + Arrays.stream(expectedFields).forEach(expectedField -> { + // 忽略 jacoco 自动生成的 $jacocoData 属性的情况 + if (expectedField.isSynthetic()) { + return; + } + // 如果是忽略的属性,则不进行比对 + if (ArrayUtil.contains(ignoreFields, expectedField.getName())) { + return; + } + // 忽略不存在的属性 + Field actualField = ReflectUtil.getField(actual.getClass(), expectedField.getName()); + if (actualField == null) { + return; + } + // 比对 + Assertions.assertEquals( + ReflectUtil.getFieldValue(expected, expectedField), + ReflectUtil.getFieldValue(actual, actualField), + String.format("Field(%s) 不匹配", expectedField.getName()) + ); + }); + } + + /** + * 比对两个对象的属性是否一致 + * + * 注意,如果 expected 存在的属性,actual 不存在的时候,会进行忽略 + * + * @param expected 期望对象 + * @param actual 实际对象 + * @param ignoreFields 忽略的属性数组 + * @return 是否一致 + */ + public static boolean isPojoEquals(Object expected, Object actual, String... ignoreFields) { + Field[] expectedFields = ReflectUtil.getFields(expected.getClass()); + return Arrays.stream(expectedFields).allMatch(expectedField -> { + // 如果是忽略的属性,则不进行比对 + if (ArrayUtil.contains(ignoreFields, expectedField.getName())) { + return true; + } + // 忽略不存在的属性 + Field actualField = ReflectUtil.getField(actual.getClass(), expectedField.getName()); + if (actualField == null) { + return true; + } + return Objects.equals(ReflectUtil.getFieldValue(expected, expectedField), + ReflectUtil.getFieldValue(actual, actualField)); + }); + } + + /** + * 执行方法,校验抛出的 Service 是否符合条件 + * + * @param executable 业务异常 + * @param errorCode 错误码对象 + * @param messageParams 消息参数 + */ + public static void assertServiceException(Executable executable, ErrorCode errorCode, Object... messageParams) { + // 调用方法 + ServiceException serviceException = assertThrows(ServiceException.class, executable); + // 校验错误码 + Assertions.assertEquals(errorCode.getCode(), serviceException.getCode(), "错误码不匹配"); + String message = ServiceExceptionUtil.doFormat(errorCode.getCode(), errorCode.getMsg(), messageParams); + Assertions.assertEquals(message, serviceException.getMessage(), "错误提示不匹配"); + } + +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/RandomUtils.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/RandomUtils.java new file mode 100644 index 0000000..976786c --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/core/util/RandomUtils.java @@ -0,0 +1,137 @@ +package com.iailab.framework.test.core.util; + +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.RandomUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.enums.CommonStatusEnum; +import uk.co.jemos.podam.api.PodamFactory; +import uk.co.jemos.podam.api.PodamFactoryImpl; + +import java.lang.reflect.Type; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 随机工具类 + * + * @author iailab + */ +public class RandomUtils { + + private static final int RANDOM_STRING_LENGTH = 10; + + private static final int TINYINT_MAX = 127; + + private static final int RANDOM_DATE_MAX = 30; + + private static final int RANDOM_COLLECTION_LENGTH = 5; + + private static final PodamFactory PODAM_FACTORY = new PodamFactoryImpl(); + + static { + // 字符串 + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(String.class, + (dataProviderStrategy, attributeMetadata, map) -> randomString()); + // Integer + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(Integer.class, (dataProviderStrategy, attributeMetadata, map) -> { + // 如果是 status 的字段,返回 0 或 1 + if ("status".equals(attributeMetadata.getAttributeName())) { + return RandomUtil.randomEle(CommonStatusEnum.values()).getStatus(); + } + // 如果是 type、status 结尾的字段,返回 tinyint 范围 + if (StrUtil.endWithAnyIgnoreCase(attributeMetadata.getAttributeName(), + "type", "status", "category", "scope", "result")) { + return RandomUtil.randomInt(0, TINYINT_MAX + 1); + } + return RandomUtil.randomInt(); + }); + // LocalDateTime + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(LocalDateTime.class, + (dataProviderStrategy, attributeMetadata, map) -> randomLocalDateTime()); + // Boolean + PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(Boolean.class, (dataProviderStrategy, attributeMetadata, map) -> { + // 如果是 deleted 的字段,返回非删除 + if ("deleted".equals(attributeMetadata.getAttributeName())) { + return false; + } + return RandomUtil.randomBoolean(); + }); + } + + public static String randomString() { + return RandomUtil.randomString(RANDOM_STRING_LENGTH); + } + + public static Long randomLongId() { + return RandomUtil.randomLong(0, Long.MAX_VALUE); + } + + public static Integer randomInteger() { + return RandomUtil.randomInt(0, Integer.MAX_VALUE); + } + + public static Date randomDate() { + return RandomUtil.randomDay(0, RANDOM_DATE_MAX); + } + + public static LocalDateTime randomLocalDateTime() { + // 设置 Nano 为零的原因,避免 MySQL、H2 存储不到时间戳 + return LocalDateTimeUtil.of(randomDate()).withNano(0); + } + + public static Short randomShort() { + return (short) RandomUtil.randomInt(0, Short.MAX_VALUE); + } + + public static <T> Set<T> randomSet(Class<T> clazz) { + return Stream.iterate(0, i -> i).limit(RandomUtil.randomInt(1, RANDOM_COLLECTION_LENGTH)) + .map(i -> randomPojo(clazz)).collect(Collectors.toSet()); + } + + public static Integer randomCommonStatus() { + return RandomUtil.randomEle(CommonStatusEnum.values()).getStatus(); + } + + public static String randomEmail() { + return randomString() + "@qq.com"; + } + + public static String randomURL() { + return "https://www.baidu.com/" + randomString(); + } + + @SafeVarargs + public static <T> T randomPojo(Class<T> clazz, Consumer<T>... consumers) { + T pojo = PODAM_FACTORY.manufacturePojo(clazz); + // 非空时,回调逻辑。通过它,可以实现 Pojo 的进一步处理 + if (ArrayUtil.isNotEmpty(consumers)) { + Arrays.stream(consumers).forEach(consumer -> consumer.accept(pojo)); + } + return pojo; + } + + @SafeVarargs + public static <T> T randomPojo(Class<T> clazz, Type type, Consumer<T>... consumers) { + T pojo = PODAM_FACTORY.manufacturePojo(clazz, type); + // 非空时,回调逻辑。通过它,可以实现 Pojo 的进一步处理 + if (ArrayUtil.isNotEmpty(consumers)) { + Arrays.stream(consumers).forEach(consumer -> consumer.accept(pojo)); + } + return pojo; + } + + @SafeVarargs + public static <T> List<T> randomPojoList(Class<T> clazz, Consumer<T>... consumers) { + int size = RandomUtil.randomInt(1, RANDOM_COLLECTION_LENGTH); + return Stream.iterate(0, i -> i).limit(size).map(o -> randomPojo(clazz, consumers)) + .collect(Collectors.toList()); + } + +} diff --git a/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/package-info.java b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/package-info.java new file mode 100644 index 0000000..ee6520a --- /dev/null +++ b/iailab-framework/iailab-common-test/src/main/java/com/iailab/framework/test/package-info.java @@ -0,0 +1,4 @@ +/** + * 测试组件,用于单元测试、集成测试等等 + */ +package com.iailab.framework.test; diff --git a/iailab-framework/iailab-common-web/pom.xml b/iailab-framework/iailab-common-web/pom.xml new file mode 100644 index 0000000..6fb1917 --- /dev/null +++ b/iailab-framework/iailab-common-web/pom.xml @@ -0,0 +1,99 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-web</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>Web 框架,全局异常、API 日志、脱敏、错误码等</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + <dependency> + <groupId>com.baomidou</groupId> + <artifactId>mybatis-plus-boot-starter</artifactId> <!-- 捕获mybatis全局异常 --> + </dependency> + + <!-- Spring Boot 配置所需依赖 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <optional>true</optional> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-web</artifactId> + </dependency> + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-validation</artifactId> + </dependency> + + <dependency> + <groupId>org.springframework.security</groupId> + <artifactId>spring-security-core</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,主要是 GlobalExceptionHandler 使用 –>--> + </dependency> + + <dependency> + <groupId>com.github.xiaoymin</groupId> <!-- 接口文档 --> + <artifactId>knife4j-openapi3-spring-boot-starter</artifactId> + </dependency> + <dependency> + <groupId>org.springdoc</groupId> <!-- 接口文档 --> + <artifactId>springdoc-openapi-ui</artifactId> + </dependency> + + <!-- RPC 远程调用相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-rpc</artifactId> + <optional>true</optional> + </dependency> + + <!-- 业务组件 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-module-infra-api</artifactId> <!-- 需要使用它,进行操作日志的记录 --> + <version>${revision}</version> + </dependency> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-module-system-api</artifactId> <!-- 需要使用它,进行错误码的记录 --> + <version>${revision}</version> + </dependency> + + <!-- xss --> + <dependency> + <groupId>org.jsoup</groupId> + <artifactId>jsoup</artifactId> + </dependency> + + <!-- Test 测试相关 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-inline</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogAutoConfiguration.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogAutoConfiguration.java new file mode 100644 index 0000000..8edd048 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogAutoConfiguration.java @@ -0,0 +1,62 @@ +package com.iailab.framework.apilog.config; + +import com.iailab.framework.apilog.core.filter.ApiAccessLogFilter; +import com.iailab.framework.apilog.core.interceptor.ApiAccessLogInterceptor; +import com.iailab.framework.apilog.core.service.ApiAccessLogFrameworkService; +import com.iailab.framework.apilog.core.service.ApiAccessLogFrameworkServiceImpl; +import com.iailab.framework.apilog.core.service.ApiErrorLogFrameworkService; +import com.iailab.framework.apilog.core.service.ApiErrorLogFrameworkServiceImpl; +import com.iailab.framework.common.enums.WebFilterOrderEnum; +import com.iailab.framework.web.config.WebProperties; +import com.iailab.framework.web.config.IailabWebAutoConfiguration; +import com.iailab.module.infra.api.logger.ApiAccessLogApi; +import com.iailab.module.infra.api.logger.ApiErrorLogApi; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.servlet.Filter; + +@AutoConfiguration(after = IailabWebAutoConfiguration.class) +public class IailabApiLogAutoConfiguration implements WebMvcConfigurer { + + @Bean + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public ApiAccessLogFrameworkService apiAccessLogFrameworkService(ApiAccessLogApi apiAccessLogApi) { + return new ApiAccessLogFrameworkServiceImpl(apiAccessLogApi); + } + + @Bean + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + public ApiErrorLogFrameworkService apiErrorLogFrameworkService(ApiErrorLogApi apiErrorLogApi) { + return new ApiErrorLogFrameworkServiceImpl(apiErrorLogApi); + } + + /** + * 创建 ApiAccessLogFilter Bean,记录 API 请求日志 + */ + @Bean + @ConditionalOnProperty(prefix = "iailab.access-log", value = "enable", matchIfMissing = true) // 允许使用 iailab.access-log.enable=false 禁用访问日志 + public FilterRegistrationBean<ApiAccessLogFilter> apiAccessLogFilter(WebProperties webProperties, + @Value("${spring.application.name}") String applicationName, + ApiAccessLogFrameworkService apiAccessLogFrameworkService) { + ApiAccessLogFilter filter = new ApiAccessLogFilter(webProperties, applicationName, apiAccessLogFrameworkService); + return createFilterBean(filter, WebFilterOrderEnum.API_ACCESS_LOG_FILTER); + } + + private static <T extends Filter> FilterRegistrationBean<T> createFilterBean(T filter, Integer order) { + FilterRegistrationBean<T> bean = new FilterRegistrationBean<>(filter); + bean.setOrder(order); + return bean; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new ApiAccessLogInterceptor()); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogRpcAutoConfiguration.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogRpcAutoConfiguration.java new file mode 100644 index 0000000..acb5fee --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/config/IailabApiLogRpcAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.iailab.framework.apilog.config; + +import com.iailab.module.infra.api.logger.ApiAccessLogApi; +import com.iailab.module.infra.api.logger.ApiErrorLogApi; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +/** + * API 日志使用到 Feign 的配置项 + * + * @author iailab + */ +@AutoConfiguration +@EnableFeignClients(clients = {ApiAccessLogApi.class, // 主要是引入相关的 API 服务 + ApiErrorLogApi.class}) +public class IailabApiLogRpcAutoConfiguration { +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/annotation/ApiAccessLog.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/annotation/ApiAccessLog.java new file mode 100644 index 0000000..d5ac165 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/annotation/ApiAccessLog.java @@ -0,0 +1,65 @@ +package com.iailab.framework.apilog.core.annotation; + +import com.iailab.framework.apilog.core.enums.OperateTypeEnum; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 访问日志注解 + * + * @author iailab + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiAccessLog { + + // ========== 开关字段 ========== + + /** + * 是否记录访问日志 + */ + boolean enable() default true; + /** + * 是否记录请求参数 + * + * 默认记录,主要考虑请求数据一般不大。可手动设置为 false 进行关闭 + */ + boolean requestEnable() default true; + /** + * 是否记录响应结果 + * + * 默认不记录,主要考虑响应数据可能比较大。可手动设置为 true 进行打开 + */ + boolean responseEnable() default false; + /** + * 敏感参数数组 + * + * 添加后,请求参数、响应结果不会记录该参数 + */ + String[] sanitizeKeys() default {}; + + // ========== 模块字段 ========== + + /** + * 操作模块 + * + * 为空时,会尝试读取 {@link io.swagger.v3.oas.annotations.tags.Tag#name()} 属性 + */ + String operateModule() default ""; + /** + * 操作名 + * + * 为空时,会尝试读取 {@link io.swagger.v3.oas.annotations.Operation#summary()} 属性 + */ + String operateName() default ""; + /** + * 操作分类 + * + * 实际并不是数组,因为枚举不能设置 null 作为默认值 + */ + OperateTypeEnum[] operateType() default {}; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/enums/OperateTypeEnum.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/enums/OperateTypeEnum.java new file mode 100644 index 0000000..a8fdc80 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/enums/OperateTypeEnum.java @@ -0,0 +1,51 @@ +package com.iailab.framework.apilog.core.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 操作日志的操作类型 + * + * @author ruoyi + */ +@Getter +@AllArgsConstructor +public enum OperateTypeEnum { + + /** + * 查询 + */ + GET(1), + /** + * 新增 + */ + CREATE(2), + /** + * 修改 + */ + UPDATE(3), + /** + * 删除 + */ + DELETE(4), + /** + * 导出 + */ + EXPORT(5), + /** + * 导入 + */ + IMPORT(6), + /** + * 其它 + * + * 在无法归类时,可以选择使用其它。因为还有操作名可以进一步标识 + */ + OTHER(0); + + /** + * 类型 + */ + private final Integer type; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/filter/ApiAccessLogFilter.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/filter/ApiAccessLogFilter.java new file mode 100644 index 0000000..773c55c --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/filter/ApiAccessLogFilter.java @@ -0,0 +1,251 @@ +package com.iailab.framework.apilog.core.filter; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.apilog.core.annotation.ApiAccessLog; +import com.iailab.framework.apilog.core.enums.OperateTypeEnum; +import com.iailab.framework.apilog.core.service.ApiAccessLogFrameworkService; +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.common.util.monitor.TracerUtils; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.web.config.WebProperties; +import com.iailab.framework.web.core.filter.ApiRequestFilter; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import com.iailab.module.infra.api.logger.dto.ApiAccessLogCreateReqDTO; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Iterator; +import java.util.Map; + +import static com.iailab.framework.apilog.core.interceptor.ApiAccessLogInterceptor.*; +import static com.iailab.framework.common.util.json.JsonUtils.toJsonString; + +/** + * API 访问日志 Filter + * + * 目的:记录 API 访问日志到数据库中 + * + * @author iailab + */ +@Slf4j +public class ApiAccessLogFilter extends ApiRequestFilter { + + private static final String[] SANITIZE_KEYS = new String[]{"password", "token", "accessToken", "refreshToken"}; + + private final String applicationName; + + private final ApiAccessLogFrameworkService apiAccessLogFrameworkService; + + public ApiAccessLogFilter(WebProperties webProperties, String applicationName, ApiAccessLogFrameworkService apiAccessLogFrameworkService) { + super(webProperties); + this.applicationName = applicationName; + this.apiAccessLogFrameworkService = apiAccessLogFrameworkService; + } + + @Override + @SuppressWarnings("NullableProblems") + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + // 获得开始时间 + LocalDateTime beginTime = LocalDateTime.now(); + // 提前获得参数,避免 XssFilter 过滤处理 + Map<String, String> queryString = ServletUtils.getParamMap(request); + String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null; + + try { + // 继续过滤器 + filterChain.doFilter(request, response); + // 正常执行,记录日志 + createApiAccessLog(request, beginTime, queryString, requestBody, null); + } catch (Exception ex) { + // 异常执行,记录日志 + createApiAccessLog(request, beginTime, queryString, requestBody, ex); + throw ex; + } + } + + private void createApiAccessLog(HttpServletRequest request, LocalDateTime beginTime, + Map<String, String> queryString, String requestBody, Exception ex) { + ApiAccessLogCreateReqDTO accessLog = new ApiAccessLogCreateReqDTO(); + try { + boolean enable = buildApiAccessLog(accessLog, request, beginTime, queryString, requestBody, ex); + if (!enable) { + return; + } + apiAccessLogFrameworkService.createApiAccessLog(accessLog); + } catch (Throwable th) { + log.error("[createApiAccessLog][url({}) log({}) 发生异常]", request.getRequestURI(), toJsonString(accessLog), th); + } + } + + private boolean buildApiAccessLog(ApiAccessLogCreateReqDTO accessLog, HttpServletRequest request, LocalDateTime beginTime, + Map<String, String> queryString, String requestBody, Exception ex) { + // 判断:是否要记录操作日志 + HandlerMethod handlerMethod = (HandlerMethod) request.getAttribute(ATTRIBUTE_HANDLER_METHOD); + ApiAccessLog accessLogAnnotation = null; + if (handlerMethod != null) { + accessLogAnnotation = handlerMethod.getMethodAnnotation(ApiAccessLog.class); + if (accessLogAnnotation != null && BooleanUtil.isFalse(accessLogAnnotation.enable())) { + return false; + } + } + + // 处理用户信息 + accessLog.setUserId(WebFrameworkUtils.getLoginUserId(request)) + .setUserType(WebFrameworkUtils.getLoginUserType(request)); + // 设置访问结果 + CommonResult<?> result = WebFrameworkUtils.getCommonResult(request); + if (result != null) { + accessLog.setResultCode(result.getCode()).setResultMsg(result.getMsg()); + } else if (ex != null) { + accessLog.setResultCode(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode()) + .setResultMsg(ExceptionUtil.getRootCauseMessage(ex)); + } else { + accessLog.setResultCode(GlobalErrorCodeConstants.SUCCESS.getCode()).setResultMsg(""); + } + // 设置请求字段 + accessLog.setTraceId(TracerUtils.getTraceId()).setApplicationName(applicationName) + .setRequestUrl(request.getRequestURI()).setRequestMethod(request.getMethod()) + .setUserAgent(ServletUtils.getUserAgent(request)).setUserIp(ServletUtils.getClientIP(request)); + String[] sanitizeKeys = accessLogAnnotation != null ? accessLogAnnotation.sanitizeKeys() : null; + Boolean requestEnable = accessLogAnnotation != null ? accessLogAnnotation.requestEnable() : Boolean.TRUE; + if (!BooleanUtil.isFalse(requestEnable)) { // 默认记录,所以判断 !false + Map<String, Object> requestParams = MapUtil.<String, Object>builder() + .put("query", sanitizeMap(queryString, sanitizeKeys)) + .put("body", sanitizeJson(requestBody, sanitizeKeys)).build(); + accessLog.setRequestParams(toJsonString(requestParams)); + } + Boolean responseEnable = accessLogAnnotation != null ? accessLogAnnotation.responseEnable() : Boolean.FALSE; + if (BooleanUtil.isTrue(responseEnable)) { // 默认不记录,默认强制要求 true + accessLog.setResponseBody(sanitizeJson(result, sanitizeKeys)); + } + // 持续时间 + accessLog.setBeginTime(beginTime).setEndTime(LocalDateTime.now()) + .setDuration((int) LocalDateTimeUtil.between(accessLog.getBeginTime(), accessLog.getEndTime(), ChronoUnit.MILLIS)); + + // 操作模块 + if (handlerMethod != null) { + Tag tagAnnotation = handlerMethod.getBeanType().getAnnotation(Tag.class); + Operation operationAnnotation = handlerMethod.getMethodAnnotation(Operation.class); + String operateModule = accessLogAnnotation != null ? accessLogAnnotation.operateModule() : + tagAnnotation != null ? StrUtil.nullToDefault(tagAnnotation.name(), tagAnnotation.description()) : null; + String operateName = accessLogAnnotation != null ? accessLogAnnotation.operateName() : + operationAnnotation != null ? operationAnnotation.summary() : null; + OperateTypeEnum operateType = accessLogAnnotation != null && accessLogAnnotation.operateType().length > 0 ? + accessLogAnnotation.operateType()[0] : parseOperateLogType(request); + accessLog.setOperateModule(operateModule).setOperateName(operateName).setOperateType(operateType.getType()); + } + return true; + } + + // ========== 解析 @ApiAccessLog、@Swagger 注解 ========== + + private static OperateTypeEnum parseOperateLogType(HttpServletRequest request) { + RequestMethod requestMethod = ArrayUtil.firstMatch(method -> + StrUtil.equalsAnyIgnoreCase(method.name(), request.getMethod()), RequestMethod.values()); + if (requestMethod == null) { + return OperateTypeEnum.OTHER; + } + switch (requestMethod) { + case GET: + return OperateTypeEnum.GET; + case POST: + return OperateTypeEnum.CREATE; + case PUT: + return OperateTypeEnum.UPDATE; + case DELETE: + return OperateTypeEnum.DELETE; + default: + return OperateTypeEnum.OTHER; + } + } + + // ========== 请求和响应的脱敏逻辑,移除类似 password、token 等敏感字段 ========== + + private static String sanitizeMap(Map<String, ?> map, String[] sanitizeKeys) { + if (CollUtil.isNotEmpty(map)) { + return null; + } + if (sanitizeKeys != null) { + MapUtil.removeAny(map, sanitizeKeys); + } + MapUtil.removeAny(map, SANITIZE_KEYS); + return JsonUtils.toJsonString(map); + } + + private static String sanitizeJson(String jsonString, String[] sanitizeKeys) { + if (StrUtil.isEmpty(jsonString)) { + return null; + } + try { + JsonNode rootNode = JsonUtils.parseTree(jsonString); + sanitizeJson(rootNode, sanitizeKeys); + return JsonUtils.toJsonString(rootNode); + } catch (Exception e) { + // 脱敏失败的情况下,直接忽略异常,避免影响用户请求 + log.error("[sanitizeJson][脱敏({}) 发生异常]", jsonString, e); + return jsonString; + } + } + + private static String sanitizeJson(CommonResult<?> commonResult, String[] sanitizeKeys) { + if (commonResult == null) { + return null; + } + String jsonString = toJsonString(commonResult); + try { + JsonNode rootNode = JsonUtils.parseTree(jsonString); + sanitizeJson(rootNode.get("data"), sanitizeKeys); // 只处理 data 字段,不处理 code、msg 字段,避免错误被脱敏掉 + return JsonUtils.toJsonString(rootNode); + } catch (Exception e) { + // 脱敏失败的情况下,直接忽略异常,避免影响用户请求 + log.error("[sanitizeJson][脱敏({}) 发生异常]", jsonString, e); + return jsonString; + } + } + + private static void sanitizeJson(JsonNode node, String[] sanitizeKeys) { + // 情况一:数组,遍历处理 + if (node.isArray()) { + for (JsonNode childNode : node) { + sanitizeJson(childNode, sanitizeKeys); + } + return; + } + // 情况二:非 Object,只是某个值,直接返回 + if (!node.isObject()) { + return; + } + // 情况三:Object,遍历处理 + Iterator<Map.Entry<String, JsonNode>> iterator = node.fields(); + while (iterator.hasNext()) { + Map.Entry<String, JsonNode> entry = iterator.next(); + if (ArrayUtil.contains(sanitizeKeys, entry.getKey()) + || ArrayUtil.contains(SANITIZE_KEYS, entry.getKey())) { + iterator.remove(); + continue; + } + sanitizeJson(entry.getValue(), sanitizeKeys); + } + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java new file mode 100644 index 0000000..f22500a --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/interceptor/ApiAccessLogInterceptor.java @@ -0,0 +1,103 @@ +package com.iailab.framework.apilog.core.interceptor; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.common.util.spring.SpringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StopWatch; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.IntStream; + +/** + * API 访问日志 Interceptor + * + * 目的:在非 prod 环境时,打印 request 和 response 两条日志到日志文件(控制台)中。 + * + * @author iailab + */ +@Slf4j +public class ApiAccessLogInterceptor implements HandlerInterceptor { + + public static final String ATTRIBUTE_HANDLER_METHOD = "HANDLER_METHOD"; + + private static final String ATTRIBUTE_STOP_WATCH = "ApiAccessLogInterceptor.StopWatch"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 记录 HandlerMethod,提供给 ApiAccessLogFilter 使用 + HandlerMethod handlerMethod = handler instanceof HandlerMethod ? (HandlerMethod) handler : null; + if (handlerMethod != null) { + request.setAttribute(ATTRIBUTE_HANDLER_METHOD, handlerMethod); + } + + // 打印 request 日志 + if (!SpringUtils.isProd()) { + Map<String, String> queryString = ServletUtils.getParamMap(request); + String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null; + if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) { + log.info("[preHandle][开始请求 URL({}) 无参数]", request.getRequestURI()); + } else { + log.info("[preHandle][开始请求 URL({}) 参数({})]", request.getRequestURI(), + StrUtil.blankToDefault(requestBody, queryString.toString())); + } + // 计时 + StopWatch stopWatch = new StopWatch(); + stopWatch.start(); + request.setAttribute(ATTRIBUTE_STOP_WATCH, stopWatch); + // 打印 Controller 路径 + printHandlerMethodPosition(handlerMethod); + } + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + // 打印 response 日志 + if (!SpringUtils.isProd()) { + StopWatch stopWatch = (StopWatch) request.getAttribute(ATTRIBUTE_STOP_WATCH); + stopWatch.stop(); + log.info("[afterCompletion][完成请求 URL({}) 耗时({} ms)]", + request.getRequestURI(), stopWatch.getTotalTimeMillis()); + } + } + + /** + * 打印 Controller 方法路径 + */ + private void printHandlerMethodPosition(HandlerMethod handlerMethod) { + if (handlerMethod == null) { + return; + } + Method method = handlerMethod.getMethod(); + Class<?> clazz = method.getDeclaringClass(); + try { + // 获取 method 的 lineNumber + List<String> clazzContents = FileUtil.readUtf8Lines( + ResourceUtil.getResource(null, clazz).getPath().replace("/target/classes/", "/src/main/java/") + + clazz.getSimpleName() + ".java"); + Optional<Integer> lineNumber = IntStream.range(0, clazzContents.size()) + .filter(i -> clazzContents.get(i).contains(" " + method.getName() + "(")) // 简单匹配,不考虑方法重名 + .mapToObj(i -> i + 1) // 行号从 1 开始 + .findFirst(); + if (!lineNumber.isPresent()) { + return; + } + // 打印结果 + System.out.printf("\tController 方法路径:%s(%s.java:%d)\n", clazz.getName(), clazz.getSimpleName(), lineNumber.get()); + } catch (Exception ignore) { + // 忽略异常。原因:仅仅打印,非重要逻辑 + } + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkService.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkService.java new file mode 100644 index 0000000..6793b93 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkService.java @@ -0,0 +1,19 @@ +package com.iailab.framework.apilog.core.service; + +import com.iailab.module.infra.api.logger.dto.ApiAccessLogCreateReqDTO; + +/** + * API 访问日志 Framework Service 接口 + * + * @author iailab + */ +public interface ApiAccessLogFrameworkService { + + /** + * 创建 API 访问日志 + * + * @param reqDTO API 访问日志 + */ + void createApiAccessLog(ApiAccessLogCreateReqDTO reqDTO); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkServiceImpl.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkServiceImpl.java new file mode 100644 index 0000000..82501a5 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiAccessLogFrameworkServiceImpl.java @@ -0,0 +1,33 @@ +package com.iailab.framework.apilog.core.service; + +import com.iailab.module.infra.api.logger.ApiAccessLogApi; +import com.iailab.module.infra.api.logger.dto.ApiAccessLogCreateReqDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; + +/** + * API 访问日志 Framework Service 实现类 + * + * 基于 {@link ApiAccessLogApi} 服务,记录访问日志 + * + * @author iailab + */ +@RequiredArgsConstructor +@Slf4j +public class ApiAccessLogFrameworkServiceImpl implements ApiAccessLogFrameworkService { + + private final ApiAccessLogApi apiAccessLogApi; + + @Override + @Async + public void createApiAccessLog(ApiAccessLogCreateReqDTO reqDTO) { + try { + apiAccessLogApi.createApiAccessLog(reqDTO); + } catch (Throwable ex) { + // 由于 @Async 异步调用,这里打印下日志,更容易跟进 + log.error("[createApiAccessLog][url({}) log({}) 发生异常]", reqDTO.getRequestUrl(), reqDTO, ex); + } + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkService.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkService.java new file mode 100644 index 0000000..43ac23e --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkService.java @@ -0,0 +1,19 @@ +package com.iailab.framework.apilog.core.service; + +import com.iailab.module.infra.api.logger.dto.ApiErrorLogCreateReqDTO; + +/** + * API 错误日志 Framework Service 接口 + * + * @author iailab + */ +public interface ApiErrorLogFrameworkService { + + /** + * 创建 API 错误日志 + * + * @param reqDTO API 错误日志 + */ + void createApiErrorLog(ApiErrorLogCreateReqDTO reqDTO); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkServiceImpl.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkServiceImpl.java new file mode 100644 index 0000000..87055ce --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/core/service/ApiErrorLogFrameworkServiceImpl.java @@ -0,0 +1,33 @@ +package com.iailab.framework.apilog.core.service; + +import com.iailab.module.infra.api.logger.ApiErrorLogApi; +import com.iailab.module.infra.api.logger.dto.ApiErrorLogCreateReqDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; + +/** + * API 错误日志 Framework Service 实现类 + * + * 基于 {@link ApiErrorLogApi} 服务,记录错误日志 + * + * @author iailab + */ +@RequiredArgsConstructor +@Slf4j +public class ApiErrorLogFrameworkServiceImpl implements ApiErrorLogFrameworkService { + + private final ApiErrorLogApi apiErrorLogApi; + + @Override + @Async + public void createApiErrorLog(ApiErrorLogCreateReqDTO reqDTO) { + try { + apiErrorLogApi.createApiErrorLog(reqDTO); + } catch (Throwable ex) { + // 由于 @Async 异步调用,这里打印下日志,更容易跟进 + log.error("[createApiErrorLog][url({}) log({}) 发生异常]", reqDTO.getRequestUrl(), reqDTO, ex); + } + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/package-info.java new file mode 100644 index 0000000..4dec8e5 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/apilog/package-info.java @@ -0,0 +1,8 @@ +/** + * API 日志:包含两类 + * 1. API 访问日志:记录用户访问 API 的访问日志,定期归档历史日志。 + * 2. 异常日志:记录用户访问 API 的系统异常,方便日常排查问题与告警。 + * + * @author iailab + */ +package com.iailab.framework.apilog; diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/config/IailabBannerAutoConfiguration.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/config/IailabBannerAutoConfiguration.java new file mode 100644 index 0000000..ae7abfc --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/config/IailabBannerAutoConfiguration.java @@ -0,0 +1,20 @@ +package com.iailab.framework.banner.config; + +import com.iailab.framework.banner.core.BannerApplicationRunner; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +/** + * Banner 的自动配置类 + * + * @author iailab + */ +@AutoConfiguration +public class IailabBannerAutoConfiguration { + + @Bean + public BannerApplicationRunner bannerApplicationRunner() { + return new BannerApplicationRunner(); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/core/BannerApplicationRunner.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/core/BannerApplicationRunner.java new file mode 100644 index 0000000..fd4c918 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/core/BannerApplicationRunner.java @@ -0,0 +1,28 @@ +package com.iailab.framework.banner.core; + +import cn.hutool.core.thread.ThreadUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; + +import java.util.concurrent.TimeUnit; + +/** + * 项目启动成功后,提供文档相关的地址 + * + * @author iailab + */ +@Slf4j +public class BannerApplicationRunner implements ApplicationRunner { + + @Override + public void run(ApplicationArguments args) { + ThreadUtil.execute(() -> { + ThreadUtil.sleep(1, TimeUnit.SECONDS); // 延迟 1 秒,保证输出到结尾 + log.info("\n----------------------------------------------------------\n\t" + + "项目启动成功!\n\t" + + "----------------------------------------------------------"); + }); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/package-info.java new file mode 100644 index 0000000..8567532 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/banner/package-info.java @@ -0,0 +1,6 @@ +/** + * Banner 用于在 console 控制台,打印开发文档、接口文档等 + * + * @author iailab + */ +package com.iailab.framework.banner; diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/annotation/DesensitizeBy.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/annotation/DesensitizeBy.java new file mode 100644 index 0000000..1894a51 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/annotation/DesensitizeBy.java @@ -0,0 +1,32 @@ +package com.iailab.framework.desensitize.core.base.annotation; + +import com.iailab.framework.desensitize.core.base.handler.DesensitizationHandler; +import com.iailab.framework.desensitize.core.base.serializer.StringDesensitizeSerializer; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 顶级脱敏注解,自定义注解需要使用此注解 + * + * @author gaibu + */ +@Documented +@Target(ElementType.ANNOTATION_TYPE) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside // 此注解是其他所有 jackson 注解的元注解,打上了此注解的注解表明是 jackson 注解的一部分 +@JsonSerialize(using = StringDesensitizeSerializer.class) // 指定序列化器 +public @interface DesensitizeBy { + + /** + * 脱敏处理器 + */ + @SuppressWarnings("rawtypes") + Class<? extends DesensitizationHandler> handler(); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/handler/DesensitizationHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/handler/DesensitizationHandler.java new file mode 100644 index 0000000..a761c0e --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/handler/DesensitizationHandler.java @@ -0,0 +1,21 @@ +package com.iailab.framework.desensitize.core.base.handler; + +import java.lang.annotation.Annotation; + +/** + * 脱敏处理器接口 + * + * @author gaibu + */ +public interface DesensitizationHandler<T extends Annotation> { + + /** + * 脱敏 + * + * @param origin 原始字符串 + * @param annotation 注解信息 + * @return 脱敏后的字符串 + */ + String desensitize(String origin, T annotation); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java new file mode 100644 index 0000000..2d737f9 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/base/serializer/StringDesensitizeSerializer.java @@ -0,0 +1,92 @@ +package com.iailab.framework.desensitize.core.base.serializer; + +import cn.hutool.core.annotation.AnnotationUtil; +import cn.hutool.core.lang.Singleton; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.base.handler.DesensitizationHandler; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.ContextualSerializer; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import lombok.Getter; +import lombok.Setter; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; + +/** + * 脱敏序列化器 + * + * 实现 JSON 返回数据时,使用 {@link DesensitizationHandler} 对声明脱敏注解的字段,进行脱敏处理。 + * + * @author gaibu + */ +@SuppressWarnings("rawtypes") +public class StringDesensitizeSerializer extends StdSerializer<String> implements ContextualSerializer { + + @Getter + @Setter + private DesensitizationHandler desensitizationHandler; + + protected StringDesensitizeSerializer() { + super(String.class); + } + + @Override + public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) { + DesensitizeBy annotation = beanProperty.getAnnotation(DesensitizeBy.class); + if (annotation == null) { + return this; + } + // 创建一个 StringDesensitizeSerializer 对象,使用 DesensitizeBy 对应的处理器 + StringDesensitizeSerializer serializer = new StringDesensitizeSerializer(); + serializer.setDesensitizationHandler(Singleton.get(annotation.handler())); + return serializer; + } + + @Override + @SuppressWarnings("unchecked") + public void serialize(String value, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException { + if (StrUtil.isBlank(value)) { + gen.writeNull(); + return; + } + // 获取序列化字段 + Field field = getField(gen); + + // 自定义处理器 + DesensitizeBy[] annotations = AnnotationUtil.getCombinationAnnotations(field, DesensitizeBy.class); + if (ArrayUtil.isEmpty(annotations)) { + gen.writeString(value); + return; + } + for (Annotation annotation : field.getAnnotations()) { + if (AnnotationUtil.hasAnnotation(annotation.annotationType(), DesensitizeBy.class)) { + value = this.desensitizationHandler.desensitize(value, annotation); + gen.writeString(value); + return; + } + } + gen.writeString(value); + } + + /** + * 获取字段 + * + * @param generator JsonGenerator + * @return 字段 + */ + private Field getField(JsonGenerator generator) { + String currentName = generator.getOutputContext().getCurrentName(); + Object currentValue = generator.getCurrentValue(); + Class<?> currentValueClass = currentValue.getClass(); + return ReflectUtil.getField(currentValueClass, currentName); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/EmailDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/EmailDesensitize.java new file mode 100644 index 0000000..65d31f4 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/EmailDesensitize.java @@ -0,0 +1,36 @@ +package com.iailab.framework.desensitize.core.regex.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.regex.handler.EmailDesensitizationHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 邮箱脱敏注解 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = EmailDesensitizationHandler.class) +public @interface EmailDesensitize { + + /** + * 匹配的正则表达式 + */ + String regex() default "(^.)[^@]*(@.*$)"; + + /** + * 替换规则,邮箱; + * + * 比如:example@gmail.com 脱敏之后为 e****@gmail.com + */ + String replacer() default "$1****$2"; +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/RegexDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/RegexDesensitize.java new file mode 100644 index 0000000..072a6ed --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/annotation/RegexDesensitize.java @@ -0,0 +1,38 @@ +package com.iailab.framework.desensitize.core.regex.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.regex.handler.DefaultRegexDesensitizationHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 正则脱敏注解 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = DefaultRegexDesensitizationHandler.class) +public @interface RegexDesensitize { + + /** + * 匹配的正则表达式(默认匹配所有) + */ + String regex() default "^[\\s\\S]*$"; + + /** + * 替换规则,会将匹配到的字符串全部替换成 replacer + * + * 例如:regex=123; replacer=****** + * 原始字符串 123456789 + * 脱敏后字符串 ******456789 + */ + String replacer() default "******"; +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java new file mode 100644 index 0000000..c5ecf05 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/AbstractRegexDesensitizationHandler.java @@ -0,0 +1,38 @@ +package com.iailab.framework.desensitize.core.regex.handler; + +import com.iailab.framework.desensitize.core.base.handler.DesensitizationHandler; + +import java.lang.annotation.Annotation; + +/** + * 正则表达式脱敏处理器抽象类,已实现通用的方法 + * + * @author gaibu + */ +public abstract class AbstractRegexDesensitizationHandler<T extends Annotation> + implements DesensitizationHandler<T> { + + @Override + public String desensitize(String origin, T annotation) { + String regex = getRegex(annotation); + String replacer = getReplacer(annotation); + return origin.replaceAll(regex, replacer); + } + + /** + * 获取注解上的 regex 参数 + * + * @param annotation 注解信息 + * @return 正则表达式 + */ + abstract String getRegex(T annotation); + + /** + * 获取注解上的 replacer 参数 + * + * @param annotation 注解信息 + * @return 待替换的字符串 + */ + abstract String getReplacer(T annotation); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java new file mode 100644 index 0000000..7adee18 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/DefaultRegexDesensitizationHandler.java @@ -0,0 +1,21 @@ +package com.iailab.framework.desensitize.core.regex.handler; + +import com.iailab.framework.desensitize.core.regex.annotation.RegexDesensitize; + +/** + * {@link RegexDesensitize} 的正则脱敏处理器 + * + * @author gaibu + */ +public class DefaultRegexDesensitizationHandler extends AbstractRegexDesensitizationHandler<RegexDesensitize> { + + @Override + String getRegex(RegexDesensitize annotation) { + return annotation.regex(); + } + + @Override + String getReplacer(RegexDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java new file mode 100644 index 0000000..6b4d60b --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/regex/handler/EmailDesensitizationHandler.java @@ -0,0 +1,22 @@ +package com.iailab.framework.desensitize.core.regex.handler; + +import com.iailab.framework.desensitize.core.regex.annotation.EmailDesensitize; + +/** + * {@link EmailDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class EmailDesensitizationHandler extends AbstractRegexDesensitizationHandler<EmailDesensitize> { + + @Override + String getRegex(EmailDesensitize annotation) { + return annotation.regex(); + } + + @Override + String getReplacer(EmailDesensitize annotation) { + return annotation.replacer(); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/BankCardDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/BankCardDesensitize.java new file mode 100644 index 0000000..67d3d9c --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/BankCardDesensitize.java @@ -0,0 +1,40 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.BankCardDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 银行卡号 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = BankCardDesensitization.class) +public @interface BankCardDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 6; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 2; + + /** + * 替换规则,银行卡号; 比如:9988002866797031 脱敏之后为 998800********31 + */ + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java new file mode 100644 index 0000000..ff33308 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/CarLicenseDesensitize.java @@ -0,0 +1,40 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.CarLicenseDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 车牌号 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = CarLicenseDesensitization.class) +public @interface CarLicenseDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 3; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 1; + + /** + * 替换规则,车牌号;比如:粤A66666 脱敏之后为粤A6***6 + */ + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java new file mode 100644 index 0000000..7bf7094 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/ChineseNameDesensitize.java @@ -0,0 +1,40 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.ChineseNameDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 中文名 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = ChineseNameDesensitization.class) +public @interface ChineseNameDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 1; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 0; + + /** + * 替换规则,中文名;比如:刘子豪脱敏之后为刘** + */ + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java new file mode 100644 index 0000000..b43a1ff --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/FixedPhoneDesensitize.java @@ -0,0 +1,40 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.FixedPhoneDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 固定电话 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = FixedPhoneDesensitization.class) +public @interface FixedPhoneDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 4; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 2; + + /** + * 替换规则,固定电话;比如:01086551122 脱敏之后为 0108*****22 + */ + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/IdCardDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/IdCardDesensitize.java new file mode 100644 index 0000000..dde01ba --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/IdCardDesensitize.java @@ -0,0 +1,40 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.IdCardDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 身份证 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = IdCardDesensitization.class) +public @interface IdCardDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 6; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 2; + + /** + * 替换规则,身份证号码;比如:530321199204074611 脱敏之后为 530321**********11 + */ + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/MobileDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/MobileDesensitize.java new file mode 100644 index 0000000..5de127f --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/MobileDesensitize.java @@ -0,0 +1,40 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.MobileDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 手机号 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = MobileDesensitization.class) +public @interface MobileDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 3; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 4; + + /** + * 替换规则,手机号;比如:13248765917 脱敏之后为 132****5917 + */ + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/PasswordDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/PasswordDesensitize.java new file mode 100644 index 0000000..6b5ba79 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/PasswordDesensitize.java @@ -0,0 +1,42 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.PasswordDesensitization; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 密码 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = PasswordDesensitization.class) +public @interface PasswordDesensitize { + + /** + * 前缀保留长度 + */ + int prefixKeep() default 0; + + /** + * 后缀保留长度 + */ + int suffixKeep() default 0; + + /** + * 替换规则,密码; + * + * 比如:123456 脱敏之后为 ****** + */ + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/SliderDesensitize.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/SliderDesensitize.java new file mode 100644 index 0000000..c655d4e --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/annotation/SliderDesensitize.java @@ -0,0 +1,43 @@ +package com.iailab.framework.desensitize.core.slider.annotation; + +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.slider.handler.DefaultDesensitizationHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 滑动脱敏注解 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = DefaultDesensitizationHandler.class) +public @interface SliderDesensitize { + + /** + * 后缀保留长度 + */ + int suffixKeep() default 0; + + /** + * 替换规则,会将前缀后缀保留后,全部替换成 replacer + * + * 例如:prefixKeep = 1; suffixKeep = 2; replacer = "*"; + * 原始字符串 123456 + * 脱敏后 1***56 + */ + String replacer() default "*"; + + /** + * 前缀保留长度 + */ + int prefixKeep() default 0; +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java new file mode 100644 index 0000000..160fa15 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/AbstractSliderDesensitizationHandler.java @@ -0,0 +1,78 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.base.handler.DesensitizationHandler; + +import java.lang.annotation.Annotation; + +/** + * 滑动脱敏处理器抽象类,已实现通用的方法 + * + * @author gaibu + */ +public abstract class AbstractSliderDesensitizationHandler<T extends Annotation> + implements DesensitizationHandler<T> { + + @Override + public String desensitize(String origin, T annotation) { + int prefixKeep = getPrefixKeep(annotation); + int suffixKeep = getSuffixKeep(annotation); + String replacer = getReplacer(annotation); + int length = origin.length(); + + // 情况一:原始字符串长度小于等于保留长度,则原始字符串全部替换 + if (prefixKeep >= length || suffixKeep >= length) { + return buildReplacerByLength(replacer, length); + } + + // 情况二:原始字符串长度小于等于前后缀保留字符串长度,则原始字符串全部替换 + if ((prefixKeep + suffixKeep) >= length) { + return buildReplacerByLength(replacer, length); + } + + // 情况三:原始字符串长度大于前后缀保留字符串长度,则替换中间字符串 + int interval = length - prefixKeep - suffixKeep; + return origin.substring(0, prefixKeep) + + buildReplacerByLength(replacer, interval) + + origin.substring(prefixKeep + interval); + } + + /** + * 根据长度循环构建替换符 + * + * @param replacer 替换符 + * @param length 长度 + * @return 构建后的替换符 + */ + private String buildReplacerByLength(String replacer, int length) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < length; i++) { + builder.append(replacer); + } + return builder.toString(); + } + + /** + * 前缀保留长度 + * + * @param annotation 注解信息 + * @return 前缀保留长度 + */ + abstract Integer getPrefixKeep(T annotation); + + /** + * 后缀保留长度 + * + * @param annotation 注解信息 + * @return 后缀保留长度 + */ + abstract Integer getSuffixKeep(T annotation); + + /** + * 替换符 + * + * @param annotation 注解信息 + * @return 替换符 + */ + abstract String getReplacer(T annotation); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/BankCardDesensitization.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/BankCardDesensitization.java new file mode 100644 index 0000000..64a3215 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/BankCardDesensitization.java @@ -0,0 +1,27 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.BankCardDesensitize; + +/** + * {@link BankCardDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class BankCardDesensitization extends AbstractSliderDesensitizationHandler<BankCardDesensitize> { + + @Override + Integer getPrefixKeep(BankCardDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(BankCardDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(BankCardDesensitize annotation) { + return annotation.replacer(); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java new file mode 100644 index 0000000..0280f99 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/CarLicenseDesensitization.java @@ -0,0 +1,25 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.CarLicenseDesensitize; + +/** + * {@link CarLicenseDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class CarLicenseDesensitization extends AbstractSliderDesensitizationHandler<CarLicenseDesensitize> { + @Override + Integer getPrefixKeep(CarLicenseDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(CarLicenseDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(CarLicenseDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java new file mode 100644 index 0000000..0eb5bc2 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/ChineseNameDesensitization.java @@ -0,0 +1,27 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.ChineseNameDesensitize; + +/** + * {@link ChineseNameDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class ChineseNameDesensitization extends AbstractSliderDesensitizationHandler<ChineseNameDesensitize> { + + @Override + Integer getPrefixKeep(ChineseNameDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(ChineseNameDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(ChineseNameDesensitize annotation) { + return annotation.replacer(); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java new file mode 100644 index 0000000..9b9f2bb --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/DefaultDesensitizationHandler.java @@ -0,0 +1,25 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.SliderDesensitize; + +/** + * {@link SliderDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class DefaultDesensitizationHandler extends AbstractSliderDesensitizationHandler<SliderDesensitize> { + @Override + Integer getPrefixKeep(SliderDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(SliderDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(SliderDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java new file mode 100644 index 0000000..53e44f3 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/FixedPhoneDesensitization.java @@ -0,0 +1,25 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.FixedPhoneDesensitize; + +/** + * {@link FixedPhoneDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class FixedPhoneDesensitization extends AbstractSliderDesensitizationHandler<FixedPhoneDesensitize> { + @Override + Integer getPrefixKeep(FixedPhoneDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(FixedPhoneDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(FixedPhoneDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/IdCardDesensitization.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/IdCardDesensitization.java new file mode 100644 index 0000000..fde3851 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/IdCardDesensitization.java @@ -0,0 +1,25 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.IdCardDesensitize; + +/** + * {@link IdCardDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class IdCardDesensitization extends AbstractSliderDesensitizationHandler<IdCardDesensitize> { + @Override + Integer getPrefixKeep(IdCardDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(IdCardDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(IdCardDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/MobileDesensitization.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/MobileDesensitization.java new file mode 100644 index 0000000..26216aa --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/MobileDesensitization.java @@ -0,0 +1,26 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.MobileDesensitize; + +/** + * {@link MobileDesensitize} 的脱敏处理器 + * + * @author gaibu + */ +public class MobileDesensitization extends AbstractSliderDesensitizationHandler<MobileDesensitize> { + + @Override + Integer getPrefixKeep(MobileDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(MobileDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(MobileDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/PasswordDesensitization.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/PasswordDesensitization.java new file mode 100644 index 0000000..55dfc56 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/core/slider/handler/PasswordDesensitization.java @@ -0,0 +1,25 @@ +package com.iailab.framework.desensitize.core.slider.handler; + +import com.iailab.framework.desensitize.core.slider.annotation.PasswordDesensitize; + +/** + * {@link PasswordDesensitize} 的码脱敏处理器 + * + * @author gaibu + */ +public class PasswordDesensitization extends AbstractSliderDesensitizationHandler<PasswordDesensitize> { + @Override + Integer getPrefixKeep(PasswordDesensitize annotation) { + return annotation.prefixKeep(); + } + + @Override + Integer getSuffixKeep(PasswordDesensitize annotation) { + return annotation.suffixKeep(); + } + + @Override + String getReplacer(PasswordDesensitize annotation) { + return annotation.replacer(); + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/package-info.java new file mode 100644 index 0000000..0634924 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/desensitize/package-info.java @@ -0,0 +1,4 @@ +/** + * 脱敏组件:支持 JSON 返回数据时,将邮箱、手机等字段进行脱敏 + */ +package com.iailab.framework.desensitize; diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/config/IailabJacksonAutoConfiguration.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/config/IailabJacksonAutoConfiguration.java new file mode 100644 index 0000000..713525c --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/config/IailabJacksonAutoConfiguration.java @@ -0,0 +1,52 @@ +package com.iailab.framework.jackson.config; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.common.util.json.JsonUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; +import com.iailab.framework.common.util.json.databind.NumberSerializer; +import com.iailab.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer; +import com.iailab.framework.common.util.json.databind.TimestampLocalDateTimeSerializer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.context.annotation.Bean; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +@AutoConfiguration +@Slf4j +public class IailabJacksonAutoConfiguration { + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public JsonUtils jsonUtils(List<ObjectMapper> objectMappers) { + // 1.1 创建 SimpleModule 对象 + SimpleModule simpleModule = new SimpleModule(); + simpleModule + // 新增 Long 类型序列化规则,数值超过 2^53-1,在 JS 会出现精度丢失问题,因此 Long 自动序列化为字符串类型 + .addSerializer(Long.class, NumberSerializer.INSTANCE) + .addSerializer(Long.TYPE, NumberSerializer.INSTANCE) + .addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE) + .addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE) + .addSerializer(LocalTime.class, LocalTimeSerializer.INSTANCE) + .addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE) + // 新增 LocalDateTime 序列化、反序列化规则,使用 Long 时间戳 + .addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE) + .addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE); + // 1.2 注册到 objectMapper + objectMappers.forEach(objectMapper -> objectMapper.registerModule(simpleModule)); + + // 2. 设置 objectMapper 到 JsonUtils + JsonUtils.init(CollUtil.getFirst(objectMappers)); + log.info("[init][初始化 JsonUtils 成功]"); + return new JsonUtils(); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/NumberSerializer.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/NumberSerializer.java new file mode 100644 index 0000000..53a6a75 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/NumberSerializer.java @@ -0,0 +1,37 @@ +package com.iailab.framework.jackson.core.databind; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JacksonStdImpl; + +import java.io.IOException; + +/** + * Long 序列化规则 + * + * 会将超长 long 值转换为 string,解决前端 JavaScript 最大安全整数是 2^53-1 的问题 + * + * @author 星语 + */ +@JacksonStdImpl +public class NumberSerializer extends com.fasterxml.jackson.databind.ser.std.NumberSerializer { + + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + private static final long MIN_SAFE_INTEGER = -9007199254740991L; + + public static final NumberSerializer INSTANCE = new NumberSerializer(Number.class); + + public NumberSerializer(Class<? extends Number> rawType) { + super(rawType); + } + + @Override + public void serialize(Number value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // 超出范围 序列化位字符串 + if (value.longValue() > MIN_SAFE_INTEGER && value.longValue() < MAX_SAFE_INTEGER) { + super.serialize(value, gen, serializers); + } else { + gen.writeString(value.toString()); + } + } +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeDeserializer.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeDeserializer.java new file mode 100644 index 0000000..4266193 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeDeserializer.java @@ -0,0 +1,27 @@ +package com.iailab.framework.jackson.core.databind; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 基于时间戳的 LocalDateTime 反序列化器 + * + * @author 老五 + */ +public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> { + + public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer(); + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + // 将 Long 时间戳,转换为 LocalDateTime 对象 + return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getValueAsLong()), ZoneId.systemDefault()); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeSerializer.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeSerializer.java new file mode 100644 index 0000000..f6485db --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/databind/TimestampLocalDateTimeSerializer.java @@ -0,0 +1,26 @@ +package com.iailab.framework.jackson.core.databind; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 基于时间戳的 LocalDateTime 序列化器 + * + * @author 老五 + */ +public class TimestampLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> { + + public static final TimestampLocalDateTimeSerializer INSTANCE = new TimestampLocalDateTimeSerializer(); + + @Override + public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // 将 LocalDateTime 对象,转换为 Long 时间戳 + gen.writeNumber(value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/package-info.java new file mode 100644 index 0000000..922937d --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/jackson/core/package-info.java @@ -0,0 +1 @@ +package com.iailab.framework.jackson.core; diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/package-info.java new file mode 100644 index 0000000..0deb23c --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/package-info.java @@ -0,0 +1,4 @@ +/** + * Web 框架,全局异常、API 日志等 + */ +package com.iailab.framework; diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/IailabSwaggerAutoConfiguration.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/IailabSwaggerAutoConfiguration.java new file mode 100644 index 0000000..331e7f0 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/IailabSwaggerAutoConfiguration.java @@ -0,0 +1,157 @@ +package com.iailab.framework.swagger.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.IntegerSchema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springdoc.core.*; +import org.springdoc.core.customizers.OpenApiBuilderCustomizer; +import org.springdoc.core.customizers.ServerBaseUrlCustomizer; +import org.springdoc.core.providers.JavadocProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpHeaders; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.iailab.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; + +/** + * Swagger 自动配置类,基于 OpenAPI + Springdoc 实现。 + * + * 友情提示: + * 1. Springdoc 文档地址:<a href="https://github.com/springdoc/springdoc-openapi">仓库</a> + * 2. Swagger 规范,于 2015 更名为 OpenAPI 规范,本质是一个东西 + * + * @author iailab + */ +@AutoConfiguration +@ConditionalOnClass({OpenAPI.class}) +@EnableConfigurationProperties(SwaggerProperties.class) +@ConditionalOnProperty(prefix = "springdoc.api-docs", name = "enabled", havingValue = "true", matchIfMissing = true) // 设置为 false 时,禁用 +public class IailabSwaggerAutoConfiguration { + + // ========== 全局 OpenAPI 配置 ========== + + @Bean + public OpenAPI createApi(SwaggerProperties properties) { + Map<String, SecurityScheme> securitySchemas = buildSecuritySchemes(); + OpenAPI openAPI = new OpenAPI() + // 接口信息 + .info(buildInfo(properties)) + // 接口安全配置 + .components(new Components().securitySchemes(securitySchemas)) + .addSecurityItem(new SecurityRequirement().addList(HttpHeaders.AUTHORIZATION)); + securitySchemas.keySet().forEach(key -> openAPI.addSecurityItem(new SecurityRequirement().addList(key))); + return openAPI; + } + + /** + * API 摘要信息 + */ + private Info buildInfo(SwaggerProperties properties) { + return new Info() + .title(properties.getTitle()) + .description(properties.getDescription()) + .version(properties.getVersion()) + .contact(new Contact().name(properties.getAuthor()).url(properties.getUrl()).email(properties.getEmail())) + .license(new License().name(properties.getLicense()).url(properties.getLicenseUrl())); + } + + /** + * 安全模式,这里配置通过请求头 Authorization 传递 token 参数 + */ + private Map<String, SecurityScheme> buildSecuritySchemes() { + Map<String, SecurityScheme> securitySchemes = new HashMap<>(); + SecurityScheme securityScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) // 类型 + .name(HttpHeaders.AUTHORIZATION) // 请求头的 name + .in(SecurityScheme.In.HEADER); // token 所在位置 + securitySchemes.put(HttpHeaders.AUTHORIZATION, securityScheme); + return securitySchemes; + } + + /** + * 自定义 OpenAPI 处理器 + */ + @Bean + @Primary // 目的:以我们创建的 OpenAPIService Bean 为主,避免一键改包后,启动报错! + public OpenAPIService openApiBuilder(Optional<OpenAPI> openAPI, + SecurityService securityParser, + SpringDocConfigProperties springDocConfigProperties, + PropertyResolverUtils propertyResolverUtils, + Optional<List<OpenApiBuilderCustomizer>> openApiBuilderCustomizers, + Optional<List<ServerBaseUrlCustomizer>> serverBaseUrlCustomizers, + Optional<JavadocProvider> javadocProvider) { + + return new OpenAPIService(openAPI, securityParser, springDocConfigProperties, + propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider); + } + + // ========== 分组 OpenAPI 配置 ========== + + /** + * 所有模块的 API 分组 + */ + @Bean + public GroupedOpenApi allGroupedOpenApi() { + return buildGroupedOpenApi("all", ""); + } + + public static GroupedOpenApi buildGroupedOpenApi(String group) { + return buildGroupedOpenApi(group, group); + } + + public static GroupedOpenApi buildGroupedOpenApi(String group, String path) { + return GroupedOpenApi.builder() + .group(group) + .pathsToMatch("/admin-api/" + path + "/**", "/app-api/" + path + "/**") + .addOperationCustomizer((operation, handlerMethod) -> operation + .addParametersItem(buildTenantHeaderParameter()) + .addParametersItem(buildSecurityHeaderParameter())) + .build(); + } + + /** + * 构建 Tenant 租户编号请求头参数 + * + * @return 多租户参数 + */ + private static Parameter buildTenantHeaderParameter() { + return new Parameter() + .name(HEADER_TENANT_ID) // header 名 + .description("租户编号") // 描述 + .in(String.valueOf(SecurityScheme.In.HEADER)) // 请求 header + .schema(new IntegerSchema()._default(1L).name(HEADER_TENANT_ID).description("租户编号")); // 默认:使用租户编号为 1 + } + + /** + * 构建 Authorization 认证请求头参数 + * + * 解决 Knife4j <a href="https://gitee.com/xiaoym/knife4j/issues/I69QBU">Authorize 未生效,请求header里未包含参数</a> + * + * @return 认证参数 + */ + private static Parameter buildSecurityHeaderParameter() { + return new Parameter() + .name(HttpHeaders.AUTHORIZATION) // header 名 + .description("认证 Token") // 描述 + .in(String.valueOf(SecurityScheme.In.HEADER)) // 请求 header + .schema(new StringSchema()._default("Bearer test1").name(HEADER_TENANT_ID).description("认证 Token")); // 默认:使用用户编号为 1 + } + +} + diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/SwaggerProperties.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/SwaggerProperties.java new file mode 100644 index 0000000..ab3bfeb --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/config/SwaggerProperties.java @@ -0,0 +1,60 @@ +package com.iailab.framework.swagger.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import javax.validation.constraints.NotEmpty; + +/** + * Swagger 配置属性 + * + * @author iailab + */ +@ConfigurationProperties("iailab.swagger") +@Data +public class SwaggerProperties { + + /** + * 标题 + */ + @NotEmpty(message = "标题不能为空") + private String title; + /** + * 描述 + */ + @NotEmpty(message = "描述不能为空") + private String description; + /** + * 作者 + */ + @NotEmpty(message = "作者不能为空") + private String author; + /** + * 版本 + */ + @NotEmpty(message = "版本不能为空") + private String version; + /** + * url + */ + @NotEmpty(message = "扫描的 package 不能为空") + private String url; + /** + * email + */ + @NotEmpty(message = "扫描的 email 不能为空") + private String email; + + /** + * license + */ + @NotEmpty(message = "扫描的 license 不能为空") + private String license; + + /** + * license-url + */ + @NotEmpty(message = "扫描的 license-url 不能为空") + private String licenseUrl; + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/package-info.java new file mode 100644 index 0000000..f30bbfb --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/swagger/package-info.java @@ -0,0 +1,6 @@ +/** + * 基于 Swagger + Knife4j 实现 API 接口文档 + * + * @author iailab + */ +package com.iailab.framework.swagger; diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/IailabWebAutoConfiguration.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/IailabWebAutoConfiguration.java new file mode 100644 index 0000000..0a4b9cb --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/IailabWebAutoConfiguration.java @@ -0,0 +1,131 @@ +package com.iailab.framework.web.config; + +import com.iailab.framework.apilog.core.service.ApiErrorLogFrameworkService; +import com.iailab.framework.common.enums.WebFilterOrderEnum; +import com.iailab.framework.web.core.filter.CacheRequestBodyFilter; +import com.iailab.framework.web.core.filter.DemoFilter; +import com.iailab.framework.web.core.handler.GlobalExceptionHandler; +import com.iailab.framework.web.core.handler.GlobalResponseBodyHandler; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.Resource; +import javax.servlet.Filter; + +@AutoConfiguration +@EnableConfigurationProperties(WebProperties.class) +public class IailabWebAutoConfiguration implements WebMvcConfigurer { + + @Resource + private WebProperties webProperties; + /** + * 应用名 + */ + @Value("${spring.application.name}") + private String applicationName; + + @Override + public void configurePathMatch(PathMatchConfigurer configurer) { + configurePathMatch(configurer, webProperties.getAdminApi()); + configurePathMatch(configurer, webProperties.getAppApi()); + } + + /** + * 设置 API 前缀,仅仅匹配 controller 包下的 + * + * @param configurer 配置 + * @param api API 配置 + */ + private void configurePathMatch(PathMatchConfigurer configurer, WebProperties.Api api) { + AntPathMatcher antPathMatcher = new AntPathMatcher("."); + configurer.addPathPrefix(api.getPrefix(), clazz -> clazz.isAnnotationPresent(RestController.class) + && antPathMatcher.match(api.getController(), clazz.getPackage().getName())); // 仅仅匹配 controller 包 + } + + @Bean + public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogFrameworkService ApiErrorLogFrameworkService) { + return new GlobalExceptionHandler(applicationName, ApiErrorLogFrameworkService); + } + + @Bean + public GlobalResponseBodyHandler globalResponseBodyHandler() { + return new GlobalResponseBodyHandler(); + } + + @Bean + @SuppressWarnings("InstantiationOfUtilityClass") + public WebFrameworkUtils webFrameworkUtils(WebProperties webProperties) { + // 由于 WebFrameworkUtils 需要使用到 webProperties 属性,所以注册为一个 Bean + return new WebFrameworkUtils(webProperties); + } + + // ========== Filter 相关 ========== + + /** + * 创建 CorsFilter Bean,解决跨域问题 + */ + @Bean + public FilterRegistrationBean<CorsFilter> corsFilterBean() { + // 创建 CorsConfiguration 对象 + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.addAllowedOriginPattern("*"); // 设置访问源地址 + config.addAllowedHeader("*"); // 设置访问源请求头 + config.addAllowedMethod("*"); // 设置访问源请求方法 + // 创建 UrlBasedCorsConfigurationSource 对象 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); // 对接口配置跨域设置 + return createFilterBean(new CorsFilter(source), WebFilterOrderEnum.CORS_FILTER); + } + + /** + * 创建 RequestBodyCacheFilter Bean,可重复读取请求内容 + */ + @Bean + public FilterRegistrationBean<CacheRequestBodyFilter> requestBodyCacheFilter() { + return createFilterBean(new CacheRequestBodyFilter(), WebFilterOrderEnum.REQUEST_BODY_CACHE_FILTER); + } + + /** + * 创建 DemoFilter Bean,演示模式 + */ + @Bean + @ConditionalOnProperty(value = "iailab.demo", havingValue = "true") + public FilterRegistrationBean<DemoFilter> demoFilter() { + return createFilterBean(new DemoFilter(), WebFilterOrderEnum.DEMO_FILTER); + } + + public static <T extends Filter> FilterRegistrationBean<T> createFilterBean(T filter, Integer order) { + FilterRegistrationBean<T> bean = new FilterRegistrationBean<>(filter); + bean.setOrder(order); + return bean; + } + + /** + * 创建 RestTemplate 实例 + * + * @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder} + */ + @Bean + @ConditionalOnMissingBean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder.build(); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/WebProperties.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/WebProperties.java new file mode 100644 index 0000000..ecc5ab0 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/config/WebProperties.java @@ -0,0 +1,66 @@ +package com.iailab.framework.web.config; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.servlet.config.annotation.PathMatchConfigurer; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +@ConfigurationProperties(prefix = "iailab.web") +@Validated +@Data +public class WebProperties { + + @NotNull(message = "APP API 不能为空") + private Api appApi = new Api("/app-api", "**.controller.app.**"); + @NotNull(message = "Admin API 不能为空") + private Api adminApi = new Api("/admin-api", "**.controller.admin.**"); + + @NotNull(message = "Admin UI 不能为空") + private Ui adminUi; + + @Data + @AllArgsConstructor + @NoArgsConstructor + @Valid + public static class Api { + + /** + * API 前缀,实现所有 Controller 提供的 RESTFul API 的统一前缀 + * + * + * 意义:通过该前缀,避免 Swagger、Actuator 意外通过 Nginx 暴露出来给外部,带来安全性问题 + * 这样,Nginx 只需要配置转发到 /api/* 的所有接口即可。 + * + * @see IailabWebAutoConfiguration#configurePathMatch(PathMatchConfigurer) + */ + @NotEmpty(message = "API 前缀不能为空") + private String prefix; + + /** + * Controller 所在包的 Ant 路径规则 + * + * 主要目的是,给该 Controller 设置指定的 {@link #prefix} + */ + @NotEmpty(message = "Controller 所在包不能为空") + private String controller; + + } + + @Data + @Valid + public static class Ui { + + /** + * 访问地址 + */ + private String url; + + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/ApiRequestFilter.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/ApiRequestFilter.java new file mode 100644 index 0000000..d3e4e46 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/ApiRequestFilter.java @@ -0,0 +1,27 @@ +package com.iailab.framework.web.core.filter; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.web.config.WebProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.http.HttpServletRequest; + +/** + * 过滤 /admin-api、/app-api 等 API 请求的过滤器 + * + * @author iailab + */ +@RequiredArgsConstructor +public abstract class ApiRequestFilter extends OncePerRequestFilter { + + protected final WebProperties webProperties; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 只过滤 API 请求的地址 + return !StrUtil.startWithAny(request.getRequestURI(), webProperties.getAdminApi().getPrefix(), + webProperties.getAppApi().getPrefix()); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyFilter.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyFilter.java new file mode 100644 index 0000000..84a1e9a --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyFilter.java @@ -0,0 +1,31 @@ +package com.iailab.framework.web.core.filter; + +import com.iailab.framework.common.util.servlet.ServletUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Request Body 缓存 Filter,实现它的可重复读取 + * + * @author iailab + */ +public class CacheRequestBodyFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + filterChain.doFilter(new CacheRequestBodyWrapper(request), response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 只处理 json 请求内容 + return !ServletUtils.isJsonRequest(request); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyWrapper.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyWrapper.java new file mode 100644 index 0000000..18a5aa1 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/CacheRequestBodyWrapper.java @@ -0,0 +1,68 @@ +package com.iailab.framework.web.core.filter; + +import com.iailab.framework.common.util.servlet.ServletUtils; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; + +/** + * Request Body 缓存 Wrapper + * + * @author iailab + */ +public class CacheRequestBodyWrapper extends HttpServletRequestWrapper { + + /** + * 缓存的内容 + */ + private final byte[] body; + + public CacheRequestBodyWrapper(HttpServletRequest request) { + super(request); + body = ServletUtils.getBodyBytes(request); + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new InputStreamReader(this.getInputStream())); + } + + @Override + public ServletInputStream getInputStream() throws IOException { + final ByteArrayInputStream inputStream = new ByteArrayInputStream(body); + // 返回 ServletInputStream + return new ServletInputStream() { + + @Override + public int read() { + return inputStream.read(); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setReadListener(ReadListener readListener) {} + + @Override + public int available() { + return body.length; + } + + }; + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/DemoFilter.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/DemoFilter.java new file mode 100644 index 0000000..43bd5cb --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/filter/DemoFilter.java @@ -0,0 +1,35 @@ +package com.iailab.framework.web.core.filter; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.DEMO_DENY; + +/** + * 演示 Filter,禁止用户发起写操作,避免影响测试数据 + * + * @author iailab + */ +public class DemoFilter extends OncePerRequestFilter { + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String method = request.getMethod(); + return !StrUtil.equalsAnyIgnoreCase(method, "POST", "PUT", "DELETE") // 写操作时,不进行过滤率 + || WebFrameworkUtils.getLoginUserId(request) == null; // 非登录用户时,不进行过滤 + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { + // 直接返回 DEMO_DENY 的结果。即,请求不继续 + ServletUtils.writeJSON(response, CommonResult.error(DEMO_DENY)); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalExceptionHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..79930b8 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalExceptionHandler.java @@ -0,0 +1,356 @@ +package com.iailab.framework.web.core.handler; + +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.apilog.core.service.ApiErrorLogFrameworkService; +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.collection.SetUtils; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.common.util.monitor.TracerUtils; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import com.iailab.module.infra.api.logger.dto.ApiErrorLogCreateReqDTO; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.util.Assert; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.dao.DuplicateKeyException; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.validation.ValidationException; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; + +import static com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants.*; + +/** + * 全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号 + * + * @author iailab + */ +@RestControllerAdvice +@AllArgsConstructor +@Slf4j +public class GlobalExceptionHandler { + + /** + * 忽略的 ServiceException 错误提示,避免打印过多 logger + */ + public static final Set<String> IGNORE_ERROR_MESSAGES = SetUtils.asSet("无效的刷新令牌"); + + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + private final String applicationName; + + private final ApiErrorLogFrameworkService apiErrorLogFrameworkService; + + /** + * 处理所有异常,主要是提供给 Filter 使用 + * 因为 Filter 不走 SpringMVC 的流程,但是我们又需要兜底处理异常,所以这里提供一个全量的异常处理过程,保持逻辑统一。 + * + * @param request 请求 + * @param ex 异常 + * @return 通用返回 + */ + public CommonResult<?> allExceptionHandler(HttpServletRequest request, Throwable ex) { + if (ex instanceof MissingServletRequestParameterException) { + return missingServletRequestParameterExceptionHandler((MissingServletRequestParameterException) ex); + } + if (ex instanceof MethodArgumentTypeMismatchException) { + return methodArgumentTypeMismatchExceptionHandler((MethodArgumentTypeMismatchException) ex); + } + if (ex instanceof MethodArgumentNotValidException) { + return methodArgumentNotValidExceptionExceptionHandler((MethodArgumentNotValidException) ex); + } + if (ex instanceof BindException) { + return bindExceptionHandler((BindException) ex); + } + if (ex instanceof ConstraintViolationException) { + return constraintViolationExceptionHandler((ConstraintViolationException) ex); + } + if (ex instanceof ValidationException) { + return validationException((ValidationException) ex); + } + if (ex instanceof NoHandlerFoundException) { + return noHandlerFoundExceptionHandler((NoHandlerFoundException) ex); + } + if (ex instanceof HttpRequestMethodNotSupportedException) { + return httpRequestMethodNotSupportedExceptionHandler((HttpRequestMethodNotSupportedException) ex); + } + if (ex instanceof ServiceException) { + return serviceExceptionHandler((ServiceException) ex); + } + if (ex instanceof AccessDeniedException) { + return accessDeniedExceptionHandler(request, (AccessDeniedException) ex); + } + if (ex instanceof DuplicateKeyException) { + return duplicateKeyExceptionHandler((DuplicateKeyException) ex); + } + return defaultExceptionHandler(request, ex); + } + + /** + * 处理 SpringMVC 请求参数缺失 + * + * 例如说,接口上设置了 @RequestParam("xx") 参数,结果并未传递 xx 参数 + */ + @ExceptionHandler(value = MissingServletRequestParameterException.class) + public CommonResult<?> missingServletRequestParameterExceptionHandler(MissingServletRequestParameterException ex) { + log.warn("[missingServletRequestParameterExceptionHandler]", ex); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数缺失:%s", ex.getParameterName())); + } + + /** + * 处理 SpringMVC 请求参数类型错误 + * + * 例如说,接口上设置了 @RequestParam("xx") 参数为 Integer,结果传递 xx 参数类型为 String + */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public CommonResult<?> methodArgumentTypeMismatchExceptionHandler(MethodArgumentTypeMismatchException ex) { + log.warn("[missingServletRequestParameterExceptionHandler]", ex); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数类型错误:%s", ex.getMessage())); + } + + /** + * 处理 SpringMVC 参数校验不正确 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public CommonResult<?> methodArgumentNotValidExceptionExceptionHandler(MethodArgumentNotValidException ex) { + log.warn("[methodArgumentNotValidExceptionExceptionHandler]", ex); + FieldError fieldError = ex.getBindingResult().getFieldError(); + assert fieldError != null; // 断言,避免告警 + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage())); + } + + /** + * 处理 SpringMVC 参数绑定不正确,本质上也是通过 Validator 校验 + */ + @ExceptionHandler(BindException.class) + public CommonResult<?> bindExceptionHandler(BindException ex) { + log.warn("[handleBindException]", ex); + FieldError fieldError = ex.getFieldError(); + assert fieldError != null; // 断言,避免告警 + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", fieldError.getDefaultMessage())); + } + + /** + * 处理 Validator 校验不通过产生的异常 + */ + @ExceptionHandler(value = ConstraintViolationException.class) + public CommonResult<?> constraintViolationExceptionHandler(ConstraintViolationException ex) { + log.warn("[constraintViolationExceptionHandler]", ex); + ConstraintViolation<?> constraintViolation = ex.getConstraintViolations().iterator().next(); + return CommonResult.error(BAD_REQUEST.getCode(), String.format("请求参数不正确:%s", constraintViolation.getMessage())); + } + + /** + * 处理 Dubbo Consumer 本地参数校验时,抛出的 ValidationException 异常 + */ + @ExceptionHandler(value = ValidationException.class) + public CommonResult<?> validationException(ValidationException ex) { + log.warn("[constraintViolationExceptionHandler]", ex); + // 无法拼接明细的错误信息,因为 Dubbo Consumer 抛出 ValidationException 异常时,是直接的字符串信息,且人类不可读 + return CommonResult.error(BAD_REQUEST); + } + + /** + * 处理 SpringMVC 请求地址不存在 + * + * 注意,它需要设置如下两个配置项: + * 1. spring.mvc.throw-exception-if-no-handler-found 为 true + * 2. spring.mvc.static-path-pattern 为 /statics/** + */ + @ExceptionHandler(NoHandlerFoundException.class) + public CommonResult<?> noHandlerFoundExceptionHandler(NoHandlerFoundException ex) { + log.warn("[noHandlerFoundExceptionHandler]", ex); + return CommonResult.error(NOT_FOUND.getCode(), String.format("请求地址不存在:%s", ex.getRequestURL())); + } + + /** + * 处理 SpringMVC 请求方法不正确 + * + * 例如说,A 接口的方法为 GET 方式,结果请求方法为 POST 方式,导致不匹配 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public CommonResult<?> httpRequestMethodNotSupportedExceptionHandler(HttpRequestMethodNotSupportedException ex) { + log.warn("[httpRequestMethodNotSupportedExceptionHandler]", ex); + return CommonResult.error(METHOD_NOT_ALLOWED.getCode(), String.format("请求方法不正确:%s", ex.getMessage())); + } + + /** + * 处理 Spring Security 权限不足的异常 + * + * 来源是,使用 @PreAuthorize 注解,AOP 进行权限拦截 + */ + @ExceptionHandler(value = AccessDeniedException.class) + public CommonResult<?> accessDeniedExceptionHandler(HttpServletRequest req, AccessDeniedException ex) { + log.warn("[accessDeniedExceptionHandler][userId({}) 无法访问 url({})]", WebFrameworkUtils.getLoginUserId(req), + req.getRequestURL(), ex); + return CommonResult.error(FORBIDDEN); + } + + /** + * 处理业务异常 SQLIntegrityConstraintViolationException + * + * 数据库存在重复数据 + */ + @ExceptionHandler(value = DuplicateKeyException.class) + public CommonResult<?> duplicateKeyExceptionHandler(DuplicateKeyException ex) { + log.warn("[duplicateKeyExceptionHandler]", ex); + return CommonResult.error(DATA_REPETITION.getCode(), DATA_REPETITION.getMsg()); + } + + /** + * 处理业务异常 ServiceException + * + * 例如说,商品库存不足,用户手机号已存在。 + */ + @ExceptionHandler(value = ServiceException.class) + public CommonResult<?> serviceExceptionHandler(ServiceException ex) { + // 不包含的时候,才进行打印,避免 ex 堆栈过多 + if (!IGNORE_ERROR_MESSAGES.contains(ex.getMessage())) { + // 即使打印,也只打印第一层 StackTraceElement,并且使用 warn 在控制台输出,更容易看到 + StackTraceElement[] stackTrace = ex.getStackTrace(); + log.warn("[serviceExceptionHandler]\n\t{}", stackTrace[0]); + } + return CommonResult.error(ex.getCode(), ex.getMessage()); + } + + /** + * 处理系统异常,兜底处理所有的一切 + */ + @ExceptionHandler(value = Exception.class) + public CommonResult<?> defaultExceptionHandler(HttpServletRequest req, Throwable ex) { + // 情况一:处理表不存在的异常 + CommonResult<?> tableNotExistsResult = handleTableNotExists(ex); + if (tableNotExistsResult != null) { + return tableNotExistsResult; + } + + // 情况二:处理异常 + log.error("[defaultExceptionHandler]", ex); + // 插入异常日志 + createExceptionLog(req, ex); + // 返回 ERROR CommonResult + return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); + } + + private void createExceptionLog(HttpServletRequest req, Throwable e) { + // 插入错误日志 + ApiErrorLogCreateReqDTO errorLog = new ApiErrorLogCreateReqDTO(); + try { + // 初始化 errorLog + buildExceptionLog(errorLog, req, e); + // 执行插入 errorLog + apiErrorLogFrameworkService.createApiErrorLog(errorLog); + } catch (Throwable th) { + log.error("[createExceptionLog][url({}) log({}) 发生异常]", req.getRequestURI(), JsonUtils.toJsonString(errorLog), th); + } + } + + private void buildExceptionLog(ApiErrorLogCreateReqDTO errorLog, HttpServletRequest request, Throwable e) { + // 处理用户信息 + errorLog.setUserId(WebFrameworkUtils.getLoginUserId(request)); + errorLog.setUserType(WebFrameworkUtils.getLoginUserType(request)); + // 设置异常字段 + errorLog.setExceptionName(e.getClass().getName()); + errorLog.setExceptionMessage(ExceptionUtil.getMessage(e)); + errorLog.setExceptionRootCauseMessage(ExceptionUtil.getRootCauseMessage(e)); + errorLog.setExceptionStackTrace(ExceptionUtil.stacktraceToString(e)); + StackTraceElement[] stackTraceElements = e.getStackTrace(); + Assert.notEmpty(stackTraceElements, "异常 stackTraceElements 不能为空"); + StackTraceElement stackTraceElement = stackTraceElements[0]; + errorLog.setExceptionClassName(stackTraceElement.getClassName()); + errorLog.setExceptionFileName(stackTraceElement.getFileName()); + errorLog.setExceptionMethodName(stackTraceElement.getMethodName()); + errorLog.setExceptionLineNumber(stackTraceElement.getLineNumber()); + // 设置其它字段 + errorLog.setTraceId(TracerUtils.getTraceId()); + errorLog.setApplicationName(applicationName); + errorLog.setRequestUrl(request.getRequestURI()); + Map<String, Object> requestParams = MapUtil.<String, Object>builder() + .put("query", ServletUtils.getParamMap(request)) + .put("body", ServletUtils.getBody(request)).build(); + errorLog.setRequestParams(JsonUtils.toJsonString(requestParams)); + errorLog.setRequestMethod(request.getMethod()); + errorLog.setUserAgent(ServletUtils.getUserAgent(request)); + errorLog.setUserIp(ServletUtils.getClientIP(request)); + errorLog.setExceptionTime(LocalDateTime.now()); + } + + /** + * 处理 Table 不存在的异常情况 + * + * @param ex 异常 + * @return 如果是 Table 不存在的异常,则返回对应的 CommonResult + */ + private CommonResult<?> handleTableNotExists(Throwable ex) { + String message = ExceptionUtil.getRootCauseMessage(ex); + if (!message.contains("doesn't exist")) { + return null; + } + // 1. 数据报表 + if (message.contains("report_")) { + log.error("[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[报表模块 yudao-module-report - 表结构未导入][参考 https://cloud.iocoder.cn/report/ 开启]"); + } + // 2. 工作流 + if (message.contains("bpm_")) { + log.error("[工作流模块 yudao-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[工作流模块 yudao-module-bpm - 表结构未导入][参考 https://cloud.iocoder.cn/bpm/ 开启]"); + } + // 3. 微信公众号 + if (message.contains("mp_")) { + log.error("[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[微信公众号 yudao-module-mp - 表结构未导入][参考 https://cloud.iocoder.cn/mp/build/ 开启]"); + } + // 4. 商城系统 + if (StrUtil.containsAny(message, "product_", "promotion_", "trade_")) { + log.error("[商城系统 yudao-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[商城系统 yudao-module-mall - 已禁用][参考 https://cloud.iocoder.cn/mall/build/ 开启]"); + } + // 5. ERP 系统 + if (message.contains("erp_")) { + log.error("[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]"); + } + // 6. CRM 系统 + if (message.contains("crm_")) { + log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]"); + } + // 7. 支付平台 + if (message.contains("pay_")) { + log.error("[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]"); + } + // 8. AI 大模型 + if (message.contains("ai_")) { + log.error("[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); + return CommonResult.error(NOT_IMPLEMENTED.getCode(), + "[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]"); + } + return null; + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalResponseBodyHandler.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalResponseBodyHandler.java new file mode 100644 index 0000000..36b3aca --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/handler/GlobalResponseBodyHandler.java @@ -0,0 +1,45 @@ +package com.iailab.framework.web.core.handler; + +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.web.core.util.WebFrameworkUtils; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + * 全局响应结果(ResponseBody)处理器 + * + * 不同于在网上看到的很多文章,会选择自动将 Controller 返回结果包上 {@link CommonResult}, + * 在 onemall 中,是 Controller 在返回时,主动自己包上 {@link CommonResult}。 + * 原因是,GlobalResponseBodyHandler 本质上是 AOP,它不应该改变 Controller 返回的数据结构 + * + * 目前,GlobalResponseBodyHandler 的主要作用是,记录 Controller 的返回结果, + * 方便 {@link com.iailab.framework.apilog.core.filter.ApiAccessLogFilter} 记录访问日志 + */ +@ControllerAdvice +public class GlobalResponseBodyHandler implements ResponseBodyAdvice { + + @Override + @SuppressWarnings("NullableProblems") // 避免 IDEA 警告 + public boolean supports(MethodParameter returnType, Class converterType) { + if (returnType.getMethod() == null) { + return false; + } + // 只拦截返回结果为 CommonResult 类型 + return returnType.getMethod().getReturnType() == CommonResult.class; + } + + @Override + @SuppressWarnings("NullableProblems") // 避免 IDEA 警告 + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, + ServerHttpRequest request, ServerHttpResponse response) { + // 记录 Controller 结果 + WebFrameworkUtils.setCommonResult(((ServletServerHttpRequest) request).getServletRequest(), (CommonResult<?>) body); + return body; + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/util/WebFrameworkUtils.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/util/WebFrameworkUtils.java new file mode 100644 index 0000000..08cc572 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/core/util/WebFrameworkUtils.java @@ -0,0 +1,169 @@ +package com.iailab.framework.web.core.util; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.extra.servlet.ServletUtil; +import com.iailab.framework.common.enums.RpcConstants; +import com.iailab.framework.common.enums.TerminalEnum; +import com.iailab.framework.common.enums.UserTypeEnum; +import com.iailab.framework.common.pojo.CommonResult; +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.web.config.WebProperties; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletRequest; + +/** + * 专属于 web 包的工具类 + * + * @author iailab + */ +public class WebFrameworkUtils { + + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_ID = "login_user_id"; + private static final String REQUEST_ATTRIBUTE_LOGIN_USER_TYPE = "login_user_type"; + + private static final String REQUEST_ATTRIBUTE_COMMON_RESULT = "common_result"; + + public static final String HEADER_TENANT_ID = "tenant-id"; + + /** + * 终端的 Header + * + * @see com.iailab.framework.common.enums.TerminalEnum + */ + public static final String HEADER_TERMINAL = "terminal"; + + private static WebProperties properties; + + public WebFrameworkUtils(WebProperties webProperties) { + WebFrameworkUtils.properties = webProperties; + } + + /** + * 获得租户编号,从 header 中 + * 考虑到其它 framework 组件也会使用到租户编号,所以不得不放在 WebFrameworkUtils 统一提供 + * + * @param request 请求 + * @return 租户编号 + */ + public static Long getTenantId(HttpServletRequest request) { + String tenantId = request.getHeader(HEADER_TENANT_ID); + return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null; + } + + public static void setLoginUserId(ServletRequest request, Long userId) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID, userId); + } + + /** + * 设置用户类型 + * + * @param request 请求 + * @param userType 用户类型 + */ + public static void setLoginUserType(ServletRequest request, Integer userType) { + request.setAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE, userType); + } + + /** + * 获得当前用户的编号,从请求中 + * 注意:该方法仅限于 framework 框架使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Long getLoginUserId(HttpServletRequest request) { + if (request == null) { + return null; + } + return (Long) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_ID); + } + + /** + * 获得当前用户的类型 + * 注意:该方法仅限于 web 相关的 framework 组件使用!!! + * + * @param request 请求 + * @return 用户编号 + */ + public static Integer getLoginUserType(HttpServletRequest request) { + if (request == null) { + return null; + } + // 1. 优先,从 Attribute 中获取 + Integer userType = (Integer) request.getAttribute(REQUEST_ATTRIBUTE_LOGIN_USER_TYPE); + if (userType != null) { + return userType; + } + // 2. 其次,基于 URL 前缀的约定 + if (request.getServletPath().startsWith(properties.getAdminApi().getPrefix())) { + return UserTypeEnum.ADMIN.getValue(); + } + if (request.getServletPath().startsWith(properties.getAppApi().getPrefix())) { + return UserTypeEnum.MEMBER.getValue(); + } + return null; + } + + public static Integer getLoginUserType() { + HttpServletRequest request = getRequest(); + return getLoginUserType(request); + } + + public static Long getLoginUserId() { + HttpServletRequest request = getRequest(); + return getLoginUserId(request); + } + + public static Integer getTerminal() { + HttpServletRequest request = getRequest(); + if (request == null) { + return TerminalEnum.UNKNOWN.getTerminal(); + } + String terminalValue = request.getHeader(HEADER_TERMINAL); + return NumberUtil.parseInt(terminalValue, TerminalEnum.UNKNOWN.getTerminal()); + } + + public static void setCommonResult(ServletRequest request, CommonResult<?> result) { + request.setAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT, result); + } + + public static CommonResult<?> getCommonResult(ServletRequest request) { + return (CommonResult<?>) request.getAttribute(REQUEST_ATTRIBUTE_COMMON_RESULT); + } + + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes; + return servletRequestAttributes.getRequest(); + } + + /** + * 判断是否为 RPC 请求 + * + * @param request 请求 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(HttpServletRequest request) { + return request.getRequestURI().startsWith(RpcConstants.RPC_API_PREFIX); + } + + /** + * 判断是否为 RPC 请求 + * + * 约定大于配置,只要以 Api 结尾,都认为是 RPC 接口 + * + * @param className 类名 + * @return 是否为 RPC 请求 + */ + public static boolean isRpcRequest(String className) { + return className.endsWith("Api"); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/package-info.java new file mode 100644 index 0000000..53e1115 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * 针对 SpringMVC 的基础封装 + */ +package com.iailab.framework.web; diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/IailabXssAutoConfiguration.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/IailabXssAutoConfiguration.java new file mode 100644 index 0000000..ed97017 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/IailabXssAutoConfiguration.java @@ -0,0 +1,63 @@ +package com.iailab.framework.xss.config; + +import com.iailab.framework.common.enums.WebFilterOrderEnum; +import com.iailab.framework.xss.core.clean.JsoupXssCleaner; +import com.iailab.framework.xss.core.clean.XssCleaner; +import com.iailab.framework.xss.core.filter.XssFilter; +import com.iailab.framework.xss.core.json.XssStringJsonDeserializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.util.PathMatcher; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import static com.iailab.framework.web.config.IailabWebAutoConfiguration.createFilterBean; + +@AutoConfiguration +@EnableConfigurationProperties(XssProperties.class) +@ConditionalOnProperty(prefix = "iailab.xss", name = "enable", havingValue = "true", matchIfMissing = true) // 设置为 false 时,禁用 +public class IailabXssAutoConfiguration implements WebMvcConfigurer { + + /** + * Xss 清理者 + * + * @return XssCleaner + */ + @Bean + @ConditionalOnMissingBean(XssCleaner.class) + public XssCleaner xssCleaner() { + return new JsoupXssCleaner(); + } + + /** + * 注册 Jackson 的序列化器,用于处理 json 类型参数的 xss 过滤 + * + * @return Jackson2ObjectMapperBuilderCustomizer + */ + @Bean + @ConditionalOnMissingBean(name = "xssJacksonCustomizer") + @ConditionalOnBean(ObjectMapper.class) + @ConditionalOnProperty(value = "iailab.xss.enable", havingValue = "true") + public Jackson2ObjectMapperBuilderCustomizer xssJacksonCustomizer(XssProperties properties, + PathMatcher pathMatcher, + XssCleaner xssCleaner) { + // 在反序列化时进行 xss 过滤,可以替换使用 XssStringJsonSerializer,在序列化时进行处理 + return builder -> builder.deserializerByType(String.class, new XssStringJsonDeserializer(properties, pathMatcher, xssCleaner)); + } + + /** + * 创建 XssFilter Bean,解决 Xss 安全问题 + */ + @Bean + @ConditionalOnBean(XssCleaner.class) + public FilterRegistrationBean<XssFilter> xssFilter(XssProperties properties, PathMatcher pathMatcher, XssCleaner xssCleaner) { + return createFilterBean(new XssFilter(properties, pathMatcher, xssCleaner), WebFilterOrderEnum.XSS_FILTER); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/XssProperties.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/XssProperties.java new file mode 100644 index 0000000..1c4d970 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/config/XssProperties.java @@ -0,0 +1,29 @@ +package com.iailab.framework.xss.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.util.Collections; +import java.util.List; + +/** + * Xss 配置属性 + * + * @author iailab + */ +@ConfigurationProperties(prefix = "iailab.xss") +@Validated +@Data +public class XssProperties { + + /** + * 是否开启,默认为 true + */ + private boolean enable = true; + /** + * 需要排除的 URL,默认为空 + */ + private List<String> excludeUrls = Collections.emptyList(); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/JsoupXssCleaner.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/JsoupXssCleaner.java new file mode 100644 index 0000000..4636994 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/JsoupXssCleaner.java @@ -0,0 +1,64 @@ +package com.iailab.framework.xss.core.clean; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; + +/** + * 基于 JSONP 实现 XSS 过滤字符串 + */ +public class JsoupXssCleaner implements XssCleaner { + + private final Safelist safelist; + + /** + * 用于在 src 属性使用相对路径时,强制转换为绝对路径。 为空时不处理,值应为绝对路径的前缀(包含协议部分) + */ + private final String baseUri; + + /** + * 无参构造,默认使用 {@link JsoupXssCleaner#buildSafelist} 方法构建一个安全列表 + */ + public JsoupXssCleaner() { + this.safelist = buildSafelist(); + this.baseUri = ""; + } + + /** + * 构建一个 Xss 清理的 Safelist 规则。 + * 基于 Safelist#relaxed() 的基础上: + * 1. 扩展支持了 style 和 class 属性 + * 2. a 标签额外支持了 target 属性 + * 3. img 标签额外支持了 data 协议,便于支持 base64 + * + * @return Safelist + */ + private Safelist buildSafelist() { + // 使用 jsoup 提供的默认的 + Safelist relaxedSafelist = Safelist.relaxed(); + // 富文本编辑时一些样式是使用 style 来进行实现的 + // 比如红色字体 style="color:red;", 所以需要给所有标签添加 style 属性 + // 注意:style 属性会有注入风险 <img STYLE="background-image:url(javascript:alert('XSS'))"> + relaxedSafelist.addAttributes(":all", "style", "class"); + // 保留 a 标签的 target 属性 + relaxedSafelist.addAttributes("a", "target"); + // 支持img 为base64 + relaxedSafelist.addProtocols("img", "src", "data"); + + // 保留相对路径, 保留相对路径时,必须提供对应的 baseUri 属性,否则依然会被删除 + // WHITELIST.preserveRelativeLinks(false); + + // 移除 a 标签和 img 标签的一些协议限制,这会导致 xss 防注入失效,如 <img src=javascript:alert("xss")> + // 虽然可以重写 WhiteList#isSafeAttribute 来处理,但是有隐患,所以暂时不支持相对路径 + // WHITELIST.removeProtocols("a", "href", "ftp", "http", "https", "mailto"); + // WHITELIST.removeProtocols("img", "src", "http", "https"); + return relaxedSafelist; + } + + @Override + public String clean(String html) { + return Jsoup.clean(html, baseUri, safelist, new Document.OutputSettings().prettyPrint(false)); + } + +} + diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/XssCleaner.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/XssCleaner.java new file mode 100644 index 0000000..d15b77e --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/clean/XssCleaner.java @@ -0,0 +1,16 @@ +package com.iailab.framework.xss.core.clean; + +/** + * 对 html 文本中的有 Xss 风险的数据进行清理 + */ +public interface XssCleaner { + + /** + * 清理有 Xss 风险的文本 + * + * @param html 原 html + * @return 清理后的 html + */ + String clean(String html); + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssFilter.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssFilter.java new file mode 100644 index 0000000..59c9347 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssFilter.java @@ -0,0 +1,52 @@ +package com.iailab.framework.xss.core.filter; + +import com.iailab.framework.xss.config.XssProperties; +import com.iailab.framework.xss.core.clean.XssCleaner; +import lombok.AllArgsConstructor; +import org.springframework.util.PathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * Xss 过滤器 + * + * @author iailab + */ +@AllArgsConstructor +public class XssFilter extends OncePerRequestFilter { + + /** + * 属性 + */ + private final XssProperties properties; + /** + * 路径匹配器 + */ + private final PathMatcher pathMatcher; + + private final XssCleaner xssCleaner; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + filterChain.doFilter(new XssRequestWrapper(request, xssCleaner), response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 如果关闭,则不过滤 + if (!properties.isEnable()) { + return true; + } + + // 如果匹配到无需过滤,则不过滤 + String uri = request.getRequestURI(); + return properties.getExcludeUrls().stream().anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, uri)); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssRequestWrapper.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssRequestWrapper.java new file mode 100644 index 0000000..04acb35 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/filter/XssRequestWrapper.java @@ -0,0 +1,92 @@ +package com.iailab.framework.xss.core.filter; + +import com.iailab.framework.xss.core.clean.XssCleaner; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Xss 请求 Wrapper + * + * @author iailab + */ +public class XssRequestWrapper extends HttpServletRequestWrapper { + + private final XssCleaner xssCleaner; + + public XssRequestWrapper(HttpServletRequest request, XssCleaner xssCleaner) { + super(request); + this.xssCleaner = xssCleaner; + } + + // ============================ parameter ============================ + @Override + public Map<String, String[]> getParameterMap() { + Map<String, String[]> map = new LinkedHashMap<>(); + Map<String, String[]> parameters = super.getParameterMap(); + for (Map.Entry<String, String[]> entry : parameters.entrySet()) { + String[] values = entry.getValue(); + for (int i = 0; i < values.length; i++) { + values[i] = xssCleaner.clean(values[i]); + } + map.put(entry.getKey(), values); + } + return map; + } + + @Override + public String[] getParameterValues(String name) { + String[] values = super.getParameterValues(name); + if (values == null) { + return null; + } + int count = values.length; + String[] encodedValues = new String[count]; + for (int i = 0; i < count; i++) { + encodedValues[i] = xssCleaner.clean(values[i]); + } + return encodedValues; + } + + @Override + public String getParameter(String name) { + String value = super.getParameter(name); + if (value == null) { + return null; + } + return xssCleaner.clean(value); + } + + // ============================ attribute ============================ + @Override + public Object getAttribute(String name) { + Object value = super.getAttribute(name); + if (value instanceof String) { + return xssCleaner.clean((String) value); + } + return value; + } + + // ============================ header ============================ + @Override + public String getHeader(String name) { + String value = super.getHeader(name); + if (value == null) { + return null; + } + return xssCleaner.clean(value); + } + + // ============================ queryString ============================ + @Override + public String getQueryString() { + String value = super.getQueryString(); + if (value == null) { + return null; + } + return xssCleaner.clean(value); + } + +} diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/json/XssStringJsonDeserializer.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/json/XssStringJsonDeserializer.java new file mode 100644 index 0000000..83f6f94 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/core/json/XssStringJsonDeserializer.java @@ -0,0 +1,82 @@ +package com.iailab.framework.xss.core.json; + +import com.iailab.framework.common.util.servlet.ServletUtils; +import com.iailab.framework.xss.config.XssProperties; +import com.iailab.framework.xss.core.clean.XssCleaner; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.PathMatcher; + +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +/** + * XSS 过滤 jackson 反序列化器。 + * 在反序列化的过程中,会对字符串进行 XSS 过滤。 + * + * @author Hccake + */ +@Slf4j +@AllArgsConstructor +public class XssStringJsonDeserializer extends StringDeserializer { + + /** + * 属性 + */ + private final XssProperties properties; + /** + * 路径匹配器 + */ + private final PathMatcher pathMatcher; + + private final XssCleaner xssCleaner; + + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + // 1. 白名单 URL 的处理 + HttpServletRequest request = ServletUtils.getRequest(); + if (request != null) { + String uri = ServletUtils.getRequest().getRequestURI(); + if (properties.getExcludeUrls().stream().anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, uri))) { + return p.getText(); + } + } + + // 2. 真正使用 xssCleaner 进行过滤 + if (p.hasToken(JsonToken.VALUE_STRING)) { + return xssCleaner.clean(p.getText()); + } + JsonToken t = p.currentToken(); + // [databind#381] + if (t == JsonToken.START_ARRAY) { + return _deserializeFromArray(p, ctxt); + } + // need to gracefully handle byte[] data, as base64 + if (t == JsonToken.VALUE_EMBEDDED_OBJECT) { + Object ob = p.getEmbeddedObject(); + if (ob == null) { + return null; + } + if (ob instanceof byte[]) { + return ctxt.getBase64Variant().encode((byte[]) ob, false); + } + // otherwise, try conversion using toString()... + return ob.toString(); + } + // 29-Jun-2020, tatu: New! "Scalar from Object" (mostly for XML) + if (t == JsonToken.START_OBJECT) { + return ctxt.extractScalarFromObject(p, this, _valueClass); + } + + if (t.isScalarValue()) { + String text = p.getValueAsString(); + return xssCleaner.clean(text); + } + return (String) ctxt.handleUnexpectedToken(_valueClass, p); + } +} + diff --git a/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/package-info.java b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/package-info.java new file mode 100644 index 0000000..8e6ce67 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/java/com/iailab/framework/xss/package-info.java @@ -0,0 +1,6 @@ +/** + * 针对 XSS 的基础封装 + * + * XSS 说明:https://tech.meituan.com/2018/09/27/fe-security.html + */ +package com.iailab.framework.xss; diff --git a/iailab-framework/iailab-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..e7ad084 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,6 @@ +com.iailab.framework.apilog.config.IailabApiLogAutoConfiguration +com.iailab.framework.jackson.config.IailabJacksonAutoConfiguration +com.iailab.framework.swagger.config.IailabSwaggerAutoConfiguration +com.iailab.framework.web.config.IailabWebAutoConfiguration +com.iailab.framework.apilog.config.IailabApiLogRpcAutoConfiguration +com.iailab.framework.banner.config.IailabBannerAutoConfiguration \ No newline at end of file diff --git a/iailab-framework/iailab-common-web/src/main/resources/banner.txt b/iailab-framework/iailab-common-web/src/main/resources/banner.txt new file mode 100644 index 0000000..65b7f42 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/main/resources/banner.txt @@ -0,0 +1,12 @@ +iailab +Application Version: ${iailab.info.version} +Spring Boot Version: ${spring-boot.version} + + ██ ██ ██ ██ ██ ██ ████ +░░ ░░ ░██ ░██ ██████ ░██ ░██ ░██░ + ██ ██████ ██ ░██ ██████ ░██ ░██░░░██ ░██ ██████ ██████ ██████ ██████ ██████ ██████████ +░██ ░░░░░░██ ░██ ░██ ░░░░░░██ ░██████ ░██ ░██ ░██ ░░░░░░██ ░░░██░ ░░░██░ ██░░░░██░░██░░█░░██░░██░░██ +░██ ███████ ░██ ░██ ███████ ░██░░░██ ░██████ ░██ ███████ ░██ ░██ ░██ ░██ ░██ ░ ░██ ░██ ░██ +░██ ██░░░░██ ░██ ░██ ██░░░░██ ░██ ░██ ░██░░░ ░██ ██░░░░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ +░██░░████████░██ ███░░████████░██████ ░██ ███░░████████ ░░██ ░██ ░░██████ ░███ ███ ░██ ░██ +░░ ░░░░░░░░ ░░ ░░░ ░░░░░░░░ ░░░░░ ░░ ░░░ ░░░░░░░░ ░░ ░░ ░░░░░░ ░░░ ░░░ ░░ ░░ diff --git a/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/DesensitizeTest.java b/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/DesensitizeTest.java new file mode 100644 index 0000000..bb7a62a --- /dev/null +++ b/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/DesensitizeTest.java @@ -0,0 +1,100 @@ +package com.iailab.framework.desensitize.core; + +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.desensitize.core.regex.annotation.EmailDesensitize; +import com.iailab.framework.desensitize.core.regex.annotation.RegexDesensitize; +import com.iailab.framework.desensitize.core.annotation.Address; +import com.iailab.framework.desensitize.core.slider.annotation.BankCardDesensitize; +import com.iailab.framework.desensitize.core.slider.annotation.CarLicenseDesensitize; +import com.iailab.framework.desensitize.core.slider.annotation.ChineseNameDesensitize; +import com.iailab.framework.desensitize.core.slider.annotation.FixedPhoneDesensitize; +import com.iailab.framework.desensitize.core.slider.annotation.IdCardDesensitize; +import com.iailab.framework.desensitize.core.slider.annotation.PasswordDesensitize; +import com.iailab.framework.desensitize.core.slider.annotation.MobileDesensitize; +import com.iailab.framework.desensitize.core.slider.annotation.SliderDesensitize; +import lombok.Data; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link DesensitizeTest} 的单元测试 + */ +@ExtendWith(MockitoExtension.class) +public class DesensitizeTest { + + @Test + public void test() { + // 准备参数 + DesensitizeDemo desensitizeDemo = new DesensitizeDemo(); + desensitizeDemo.setNickname("iailab"); + desensitizeDemo.setBankCard("9988002866797031"); + desensitizeDemo.setCarLicense("粤A66666"); + desensitizeDemo.setFixedPhone("01086551122"); + desensitizeDemo.setIdCard("530321199204074611"); + desensitizeDemo.setPassword("123456"); + desensitizeDemo.setPhoneNumber("13248765917"); + desensitizeDemo.setSlider1("ABCDEFG"); + desensitizeDemo.setSlider2("ABCDEFG"); + desensitizeDemo.setSlider3("ABCDEFG"); + desensitizeDemo.setEmail("1@email.com"); + desensitizeDemo.setRegex("你好,我是iailab"); + desensitizeDemo.setAddress("北京市海淀区上地十街10号"); + desensitizeDemo.setOrigin("iailab"); + + // 调用 + DesensitizeDemo d = JsonUtils.parseObject(JsonUtils.toJsonString(desensitizeDemo), DesensitizeDemo.class); + // 断言 + assertNotNull(d); + assertEquals("芋***", d.getNickname()); + assertEquals("998800********31", d.getBankCard()); + assertEquals("粤A6***6", d.getCarLicense()); + assertEquals("0108*****22", d.getFixedPhone()); + assertEquals("530321**********11", d.getIdCard()); + assertEquals("******", d.getPassword()); + assertEquals("132****5917", d.getPhoneNumber()); + assertEquals("#######", d.getSlider1()); + assertEquals("ABC*EFG", d.getSlider2()); + assertEquals("*******", d.getSlider3()); + assertEquals("1****@email.com", d.getEmail()); + assertEquals("你好,我是*", d.getRegex()); + assertEquals("北京市海淀区上地十街10号*", d.getAddress()); + assertEquals("iailab", d.getOrigin()); + } + + @Data + public static class DesensitizeDemo { + + @ChineseNameDesensitize + private String nickname; + @BankCardDesensitize + private String bankCard; + @CarLicenseDesensitize + private String carLicense; + @FixedPhoneDesensitize + private String fixedPhone; + @IdCardDesensitize + private String idCard; + @PasswordDesensitize + private String password; + @MobileDesensitize + private String phoneNumber; + @SliderDesensitize(prefixKeep = 6, suffixKeep = 1, replacer = "#") + private String slider1; + @SliderDesensitize(prefixKeep = 3, suffixKeep = 3) + private String slider2; + @SliderDesensitize(prefixKeep = 10) + private String slider3; + @EmailDesensitize + private String email; + @RegexDesensitize(regex = "iailab", replacer = "*") + private String regex; + @Address + private String address; + private String origin; + + } + +} diff --git a/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/annotation/Address.java b/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/annotation/Address.java new file mode 100644 index 0000000..01826e4 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/annotation/Address.java @@ -0,0 +1,30 @@ +package com.iailab.framework.desensitize.core.annotation; + +import com.iailab.framework.desensitize.core.DesensitizeTest; +import com.iailab.framework.desensitize.core.base.annotation.DesensitizeBy; +import com.iailab.framework.desensitize.core.handler.AddressHandler; +import com.fasterxml.jackson.annotation.JacksonAnnotationsInside; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 地址 + * + * 用于 {@link DesensitizeTest} 测试使用 + * + * @author gaibu + */ +@Documented +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@JacksonAnnotationsInside +@DesensitizeBy(handler = AddressHandler.class) +public @interface Address { + + String replacer() default "*"; + +} diff --git a/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/handler/AddressHandler.java b/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/handler/AddressHandler.java new file mode 100644 index 0000000..6172c24 --- /dev/null +++ b/iailab-framework/iailab-common-web/src/test/java/com/iailab/framework/desensitize/core/handler/AddressHandler.java @@ -0,0 +1,19 @@ +package com.iailab.framework.desensitize.core.handler; + +import com.iailab.framework.desensitize.core.DesensitizeTest; +import com.iailab.framework.desensitize.core.base.handler.DesensitizationHandler; +import com.iailab.framework.desensitize.core.annotation.Address; + +/** + * {@link Address} 的脱敏处理器 + * + * 用于 {@link DesensitizeTest} 测试使用 + */ +public class AddressHandler implements DesensitizationHandler<Address> { + + @Override + public String desensitize(String origin, Address annotation) { + return origin + annotation.replacer(); + } + +} diff --git a/iailab-framework/iailab-common-websocket/pom.xml b/iailab-framework/iailab-common-websocket/pom.xml new file mode 100644 index 0000000..a566d80 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/pom.xml @@ -0,0 +1,73 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common-websocket</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>WebSocket 框架,支持多节点的广播</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + + <dependencies> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common</artifactId> + </dependency> + + <!-- Web 相关 --> + <dependency> + <!-- 为什么是 websocket 依赖 security 呢?而不是 security 拓展 websocket 呢? + 因为 websocket 和 LoginUser 当前登录的用户有一定的相关性,具体可见 WebSocketSessionManagerImpl 逻辑。 + 如果让 security 拓展 websocket 的话,会导致 websocket 组件的封装很散,进而增大理解成本。 + --> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-security</artifactId> + <scope>provided</scope> + </dependency> + + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-websocket</artifactId> + </dependency> + + <!-- 消息队列相关 --> + <dependency> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-mq</artifactId> + </dependency> +<!-- <dependency>--> +<!-- <groupId>org.springframework.kafka</groupId>--> +<!-- <artifactId>spring-kafka</artifactId>--> +<!-- <optional>true</optional>--> +<!-- </dependency>--> +<!-- <dependency>--> +<!-- <groupId>org.springframework.amqp</groupId>--> +<!-- <artifactId>spring-rabbit</artifactId>--> +<!-- <optional>true</optional>--> +<!-- </dependency>--> +<!-- <dependency>--> +<!-- <groupId>org.apache.rocketmq</groupId>--> +<!-- <artifactId>rocketmq-spring-boot-starter</artifactId>--> +<!-- <optional>true</optional>--> +<!-- </dependency>--> + + <!-- 业务组件 --> + <dependency> + <!-- 为什么要依赖 tenant 组件? + 因为广播某个类型的用户时候,需要根据租户过滤下,避免广播到别的租户! + --> + <groupId>com.iailab</groupId> + <artifactId>iailab-common-biz-tenant</artifactId> + <scope>provided</scope> + </dependency> + </dependencies> + +</project> \ No newline at end of file diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/IailabWebSocketAutoConfiguration.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/IailabWebSocketAutoConfiguration.java new file mode 100644 index 0000000..459a5b9 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/IailabWebSocketAutoConfiguration.java @@ -0,0 +1,177 @@ +package com.iailab.framework.websocket.config; + +import com.iailab.framework.mq.redis.config.IailabRedisMQConsumerAutoConfiguration; +import com.iailab.framework.mq.redis.core.RedisMQTemplate; +import com.iailab.framework.websocket.core.handler.JsonWebSocketMessageHandler; +import com.iailab.framework.websocket.core.listener.WebSocketMessageListener; +import com.iailab.framework.websocket.core.security.LoginUserHandshakeInterceptor; +import com.iailab.framework.websocket.core.sender.kafka.KafkaWebSocketMessageConsumer; +import com.iailab.framework.websocket.core.sender.kafka.KafkaWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.local.LocalWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.rabbitmq.RabbitMQWebSocketMessageConsumer; +import com.iailab.framework.websocket.core.sender.rabbitmq.RabbitMQWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.redis.RedisWebSocketMessageConsumer; +import com.iailab.framework.websocket.core.sender.redis.RedisWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.rocketmq.RocketMQWebSocketMessageConsumer; +import com.iailab.framework.websocket.core.sender.rocketmq.RocketMQWebSocketMessageSender; +import com.iailab.framework.websocket.core.session.WebSocketSessionHandlerDecorator; +import com.iailab.framework.websocket.core.session.WebSocketSessionManager; +import com.iailab.framework.websocket.core.session.WebSocketSessionManagerImpl; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.List; + +/** + * WebSocket 自动配置 + * + * @author xingyu4j + */ +@AutoConfiguration(before = IailabRedisMQConsumerAutoConfiguration.class) // before IailabRedisMQConsumerAutoConfiguration 的原因是,需要保证 RedisWebSocketMessageConsumer 先创建,才能创建 RedisMessageListenerContainer +@EnableWebSocket // 开启 websocket +@ConditionalOnProperty(prefix = "iailab.websocket", value = "enable", matchIfMissing = true) // 允许使用 iailab.websocket.enable=false 禁用 websocket +@EnableConfigurationProperties(WebSocketProperties.class) +public class IailabWebSocketAutoConfiguration { + + @Bean + public WebSocketConfigurer webSocketConfigurer(HandshakeInterceptor[] handshakeInterceptors, + WebSocketHandler webSocketHandler, + WebSocketProperties webSocketProperties) { + return registry -> registry + // 添加 WebSocketHandler + .addHandler(webSocketHandler, webSocketProperties.getPath()) + .addInterceptors(handshakeInterceptors) + // 允许跨域,否则前端连接会直接断开 + .setAllowedOriginPatterns("*"); + } + + @Bean + public HandshakeInterceptor handshakeInterceptor() { + return new LoginUserHandshakeInterceptor(); + } + + @Bean + public WebSocketHandler webSocketHandler(WebSocketSessionManager sessionManager, + List<? extends WebSocketMessageListener<?>> messageListeners) { + // 1. 创建 JsonWebSocketMessageHandler 对象,处理消息 + JsonWebSocketMessageHandler messageHandler = new JsonWebSocketMessageHandler(messageListeners); + // 2. 创建 WebSocketSessionHandlerDecorator 对象,处理连接 + return new WebSocketSessionHandlerDecorator(messageHandler, sessionManager); + } + + @Bean + public WebSocketSessionManager webSocketSessionManager() { + return new WebSocketSessionManagerImpl(); + } + + // ==================== Sender 相关 ==================== + + @Configuration + @ConditionalOnProperty(prefix = "iailab.websocket", name = "sender-type", havingValue = "local", matchIfMissing = true) + public class LocalWebSocketMessageSenderConfiguration { + + @Bean + public LocalWebSocketMessageSender localWebSocketMessageSender(WebSocketSessionManager sessionManager) { + return new LocalWebSocketMessageSender(sessionManager); + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "iailab.websocket", name = "sender-type", havingValue = "redis", matchIfMissing = true) + public class RedisWebSocketMessageSenderConfiguration { + + @Bean + public RedisWebSocketMessageSender redisWebSocketMessageSender(WebSocketSessionManager sessionManager, + RedisMQTemplate redisMQTemplate) { + return new RedisWebSocketMessageSender(sessionManager, redisMQTemplate); + } + + @Bean + public RedisWebSocketMessageConsumer redisWebSocketMessageConsumer( + RedisWebSocketMessageSender redisWebSocketMessageSender) { + return new RedisWebSocketMessageConsumer(redisWebSocketMessageSender); + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "iailab.websocket", name = "sender-type", havingValue = "rocketmq", matchIfMissing = true) + public class RocketMQWebSocketMessageSenderConfiguration { + + @Bean + public RocketMQWebSocketMessageSender rocketMQWebSocketMessageSender( + WebSocketSessionManager sessionManager, RocketMQTemplate rocketMQTemplate, + @Value("${iailab.websocket.sender-rocketmq.topic}") String topic) { + return new RocketMQWebSocketMessageSender(sessionManager, rocketMQTemplate, topic); + } + + @Bean + public RocketMQWebSocketMessageConsumer rocketMQWebSocketMessageConsumer( + RocketMQWebSocketMessageSender rocketMQWebSocketMessageSender) { + return new RocketMQWebSocketMessageConsumer(rocketMQWebSocketMessageSender); + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "iailab.websocket", name = "sender-type", havingValue = "rabbitmq", matchIfMissing = true) + public class RabbitMQWebSocketMessageSenderConfiguration { + + @Bean + public RabbitMQWebSocketMessageSender rabbitMQWebSocketMessageSender( + WebSocketSessionManager sessionManager, RabbitTemplate rabbitTemplate, + TopicExchange websocketTopicExchange) { + return new RabbitMQWebSocketMessageSender(sessionManager, rabbitTemplate, websocketTopicExchange); + } + + @Bean + public RabbitMQWebSocketMessageConsumer rabbitMQWebSocketMessageConsumer( + RabbitMQWebSocketMessageSender rabbitMQWebSocketMessageSender) { + return new RabbitMQWebSocketMessageConsumer(rabbitMQWebSocketMessageSender); + } + + /** + * 创建 Topic Exchange + */ + @Bean + public TopicExchange websocketTopicExchange(@Value("${iailab.websocket.sender-rabbitmq.exchange}") String exchange) { + return new TopicExchange(exchange, + true, // durable: 是否持久化 + false); // exclusive: 是否排它 + } + + } + + @Configuration + @ConditionalOnProperty(prefix = "iailab.websocket", name = "sender-type", havingValue = "kafka", matchIfMissing = true) + public class KafkaWebSocketMessageSenderConfiguration { + + @Bean + public KafkaWebSocketMessageSender kafkaWebSocketMessageSender( + WebSocketSessionManager sessionManager, KafkaTemplate<Object, Object> kafkaTemplate, + @Value("${iailab.websocket.sender-kafka.topic}") String topic) { + return new KafkaWebSocketMessageSender(sessionManager, kafkaTemplate, topic); + } + + @Bean + public KafkaWebSocketMessageConsumer kafkaWebSocketMessageConsumer( + KafkaWebSocketMessageSender kafkaWebSocketMessageSender) { + return new KafkaWebSocketMessageConsumer(kafkaWebSocketMessageSender); + } + + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/WebSocketProperties.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/WebSocketProperties.java new file mode 100644 index 0000000..75e10ca --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/config/WebSocketProperties.java @@ -0,0 +1,34 @@ +package com.iailab.framework.websocket.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; + +/** + * WebSocket 配置项 + * + * @author xingyu4j + */ +@ConfigurationProperties("iailab.websocket") +@Data +@Validated +public class WebSocketProperties { + + /** + * WebSocket 的连接路径 + */ + @NotEmpty(message = "WebSocket 的连接路径不能为空") + private String path = "/ws"; + + /** + * 消息发送器的类型 + * + * 可选值:local、redis、rocketmq、kafka、rabbitmq + */ + @NotNull(message = "WebSocket 的消息发送者不能为空") + private String senderType = "local"; + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/handler/JsonWebSocketMessageHandler.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/handler/JsonWebSocketMessageHandler.java new file mode 100644 index 0000000..699e282 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/handler/JsonWebSocketMessageHandler.java @@ -0,0 +1,83 @@ +package com.iailab.framework.websocket.core.handler; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.TypeUtil; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.tenant.core.util.TenantUtils; +import com.iailab.framework.websocket.core.listener.WebSocketMessageListener; +import com.iailab.framework.websocket.core.message.JsonWebSocketMessage; +import com.iailab.framework.websocket.core.util.WebSocketFrameworkUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * JSON 格式 {@link WebSocketHandler} 实现类 + * + * 基于 {@link JsonWebSocketMessage#getType()} 消息类型,调度到对应的 {@link WebSocketMessageListener} 监听器。 + * + * @author iailab + */ +@Slf4j +public class JsonWebSocketMessageHandler extends TextWebSocketHandler { + + /** + * type 与 WebSocketMessageListener 的映射 + */ + private final Map<String, WebSocketMessageListener<Object>> listeners = new HashMap<>(); + + @SuppressWarnings({"rawtypes", "unchecked"}) + public JsonWebSocketMessageHandler(List<? extends WebSocketMessageListener> listenersList) { + listenersList.forEach((Consumer<WebSocketMessageListener>) + listener -> listeners.put(listener.getType(), listener)); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + // 1.1 空消息,跳过 + if (message.getPayloadLength() == 0) { + return; + } + // 1.2 ping 心跳消息,直接返回 pong 消息。 + if (message.getPayloadLength() == 4 && Objects.equals(message.getPayload(), "ping")) { + session.sendMessage(new TextMessage("pong")); + return; + } + + // 2.1 解析消息 + try { + JsonWebSocketMessage jsonMessage = JsonUtils.parseObject(message.getPayload(), JsonWebSocketMessage.class); + if (jsonMessage == null) { + log.error("[handleTextMessage][session({}) message({}) 解析为空]", session.getId(), message.getPayload()); + return; + } + if (StrUtil.isEmpty(jsonMessage.getType())) { + log.error("[handleTextMessage][session({}) message({}) 类型为空]", session.getId(), message.getPayload()); + return; + } + // 2.2 获得对应的 WebSocketMessageListener + WebSocketMessageListener<Object> messageListener = listeners.get(jsonMessage.getType()); + if (messageListener == null) { + log.error("[handleTextMessage][session({}) message({}) 监听器为空]", session.getId(), message.getPayload()); + return; + } + // 2.3 处理消息 + Type type = TypeUtil.getTypeArgument(messageListener.getClass(), 0); + Object messageObj = JsonUtils.parseObject(jsonMessage.getContent(), type); + Long tenantId = WebSocketFrameworkUtils.getTenantId(session); + TenantUtils.execute(tenantId, () -> messageListener.onMessage(session, messageObj)); + } catch (Throwable ex) { + log.error("[handleTextMessage][session({}) message({}) 处理异常]", session.getId(), message.getPayload()); + } + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/listener/WebSocketMessageListener.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/listener/WebSocketMessageListener.java new file mode 100644 index 0000000..9b55317 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/listener/WebSocketMessageListener.java @@ -0,0 +1,31 @@ +package com.iailab.framework.websocket.core.listener; + +import com.iailab.framework.websocket.core.message.JsonWebSocketMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * WebSocket 消息监听器接口 + * + * 目的:前端发送消息给后端后,处理对应 {@link #getType()} 类型的消息 + * + * @param <T> 泛型,消息类型 + */ +public interface WebSocketMessageListener<T> { + + /** + * 处理消息 + * + * @param session Session + * @param message 消息 + */ + void onMessage(WebSocketSession session, T message); + + /** + * 获得消息类型 + * + * @see JsonWebSocketMessage#getType() + * @return 消息类型 + */ + String getType(); + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/message/JsonWebSocketMessage.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/message/JsonWebSocketMessage.java new file mode 100644 index 0000000..e9c598f --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/message/JsonWebSocketMessage.java @@ -0,0 +1,29 @@ +package com.iailab.framework.websocket.core.message; + +import com.iailab.framework.websocket.core.listener.WebSocketMessageListener; +import lombok.Data; + +import java.io.Serializable; + +/** + * JSON 格式的 WebSocket 消息帧 + * + * @author iailab + */ +@Data +public class JsonWebSocketMessage implements Serializable { + + /** + * 消息类型 + * + * 目的:用于分发到对应的 {@link WebSocketMessageListener} 实现类 + */ + private String type; + /** + * 消息内容 + * + * 要求 JSON 对象 + */ + private String content; + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/LoginUserHandshakeInterceptor.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/LoginUserHandshakeInterceptor.java new file mode 100644 index 0000000..d1092f5 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/LoginUserHandshakeInterceptor.java @@ -0,0 +1,42 @@ +package com.iailab.framework.websocket.core.security; + +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.security.core.filter.TokenAuthenticationFilter; +import com.iailab.framework.security.core.util.SecurityFrameworkUtils; +import com.iailab.framework.websocket.core.util.WebSocketFrameworkUtils; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +/** + * 登录用户的 {@link HandshakeInterceptor} 实现类 + * + * 流程如下: + * 1. 前端连接 websocket 时,会通过拼接 ?token={token} 到 ws:// 连接后,这样它可以被 {@link TokenAuthenticationFilter} 所认证通过 + * 2. {@link LoginUserHandshakeInterceptor} 负责把 {@link LoginUser} 添加到 {@link WebSocketSession} 中 + * + * @author iailab + */ +public class LoginUserHandshakeInterceptor implements HandshakeInterceptor { + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Map<String, Object> attributes) { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser != null) { + WebSocketFrameworkUtils.setLoginUser(loginUser, attributes); + } + return true; + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler wsHandler, Exception exception) { + // do nothing + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/WebSocketAuthorizeRequestsCustomizer.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/WebSocketAuthorizeRequestsCustomizer.java new file mode 100644 index 0000000..bd760f5 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/security/WebSocketAuthorizeRequestsCustomizer.java @@ -0,0 +1,24 @@ +package com.iailab.framework.websocket.core.security; + +import com.iailab.framework.security.config.AuthorizeRequestsCustomizer; +import com.iailab.framework.websocket.config.WebSocketProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; + +/** + * WebSocket 的权限自定义 + * + * @author iailab + */ +@RequiredArgsConstructor +public class WebSocketAuthorizeRequestsCustomizer extends AuthorizeRequestsCustomizer { + + private final WebSocketProperties webSocketProperties; + + @Override + public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) { + registry.requestMatchers(webSocketProperties.getPath()).permitAll(); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/AbstractWebSocketMessageSender.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/AbstractWebSocketMessageSender.java new file mode 100644 index 0000000..ce676f0 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/AbstractWebSocketMessageSender.java @@ -0,0 +1,104 @@ +package com.iailab.framework.websocket.core.sender; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.util.json.JsonUtils; +import com.iailab.framework.websocket.core.message.JsonWebSocketMessage; +import com.iailab.framework.websocket.core.session.WebSocketSessionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * WebSocketMessageSender 实现类 + * + * @author iailab + */ +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractWebSocketMessageSender implements WebSocketMessageSender { + + private final WebSocketSessionManager sessionManager; + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + send(null, userType, userId, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + send(null, userType, null, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + send(sessionId, null, null, messageType, messageContent); + } + + /** + * 发送消息 + * + * @param sessionId Session 编号 + * @param userType 用户类型 + * @param userId 用户编号 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + public void send(String sessionId, Integer userType, Long userId, String messageType, String messageContent) { + // 1. 获得 Session 列表 + List<WebSocketSession> sessions = Collections.emptyList(); + if (StrUtil.isNotEmpty(sessionId)) { + WebSocketSession session = sessionManager.getSession(sessionId); + if (session != null) { + sessions = Collections.singletonList(session); + } + } else if (userType != null && userId != null) { + sessions = (List<WebSocketSession>) sessionManager.getSessionList(userType, userId); + } else if (userType != null) { + sessions = (List<WebSocketSession>) sessionManager.getSessionList(userType); + } + if (CollUtil.isEmpty(sessions)) { + log.info("[send][sessionId({}) userType({}) userId({}) messageType({}) messageContent({}) 未匹配到会话]", + sessionId, userType, userId, messageType, messageContent); + } + // 2. 执行发送 + doSend(sessions, messageType, messageContent); + } + + /** + * 发送消息的具体实现 + * + * @param sessions Session 列表 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + public void doSend(Collection<WebSocketSession> sessions, String messageType, String messageContent) { + JsonWebSocketMessage message = new JsonWebSocketMessage().setType(messageType).setContent(messageContent); + String payload = JsonUtils.toJsonString(message); // 关键,使用 JSON 序列化 + sessions.forEach(session -> { + // 1. 各种校验,保证 Session 可以被发送 + if (session == null) { + log.error("[doSend][session 为空, message({})]", message); + return; + } + if (!session.isOpen()) { + log.error("[doSend][session({}) 已关闭, message({})]", session.getId(), message); + return; + } + // 2. 执行发送 + try { + session.sendMessage(new TextMessage(payload)); + log.info("[doSend][session({}) 发送消息成功,message({})]", session.getId(), message); + } catch (IOException ex) { + log.error("[doSend][session({}) 发送消息失败,message({})]", session.getId(), message, ex); + } + }); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/WebSocketMessageSender.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/WebSocketMessageSender.java new file mode 100644 index 0000000..df5f111 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/WebSocketMessageSender.java @@ -0,0 +1,52 @@ +package com.iailab.framework.websocket.core.sender; + +import com.iailab.framework.common.util.json.JsonUtils; + +/** + * WebSocket 消息的发送器接口 + * + * @author iailab + */ +public interface WebSocketMessageSender { + + /** + * 发送消息给指定用户 + * + * @param userType 用户类型 + * @param userId 用户编号 + * @param messageType 消息类型 + * @param messageContent 消息内容,JSON 格式 + */ + void send(Integer userType, Long userId, String messageType, String messageContent); + + /** + * 发送消息给指定用户类型 + * + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容,JSON 格式 + */ + void send(Integer userType, String messageType, String messageContent); + + /** + * 发送消息给指定 Session + * + * @param sessionId Session 编号 + * @param messageType 消息类型 + * @param messageContent 消息内容,JSON 格式 + */ + void send(String sessionId, String messageType, String messageContent); + + default void sendObject(Integer userType, Long userId, String messageType, Object messageContent) { + send(userType, userId, messageType, JsonUtils.toJsonString(messageContent)); + } + + default void sendObject(Integer userType, String messageType, Object messageContent) { + send(userType, messageType, JsonUtils.toJsonString(messageContent)); + } + + default void sendObject(String sessionId, String messageType, Object messageContent) { + send(sessionId, messageType, JsonUtils.toJsonString(messageContent)); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessage.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessage.java new file mode 100644 index 0000000..a61a6c9 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessage.java @@ -0,0 +1,35 @@ +package com.iailab.framework.websocket.core.sender.kafka; + +import lombok.Data; + +/** + * Kafka 广播 WebSocket 的消息 + * + * @author iailab + */ +@Data +public class KafkaWebSocketMessage { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageConsumer.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageConsumer.java new file mode 100644 index 0000000..ffad2f8 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageConsumer.java @@ -0,0 +1,28 @@ +package com.iailab.framework.websocket.core.sender.kafka; + +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.kafka.annotation.KafkaListener; + +/** + * {@link KafkaWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author iailab + */ +@RequiredArgsConstructor +public class KafkaWebSocketMessageConsumer { + + private final KafkaWebSocketMessageSender rabbitMQWebSocketMessageSender; + + @RabbitHandler + @KafkaListener( + topics = "${iailab.websocket.sender-kafka.topic}", + // 在 Group 上,使用 UUID 生成其后缀。这样,启动的 Consumer 的 Group 不同,以达到广播消费的目的 + groupId = "${iailab.websocket.sender-kafka.consumer-group}" + "-" + "#{T(java.util.UUID).randomUUID()}") + public void onMessage(KafkaWebSocketMessage message) { + rabbitMQWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageSender.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageSender.java new file mode 100644 index 0000000..8801a55 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/kafka/KafkaWebSocketMessageSender.java @@ -0,0 +1,67 @@ +package com.iailab.framework.websocket.core.sender.kafka; + +import com.iailab.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.WebSocketMessageSender; +import com.iailab.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; + +import java.util.concurrent.ExecutionException; + +/** + * 基于 Kafka 的 {@link WebSocketMessageSender} 实现类 + * + * @author iailab + */ +@Slf4j +public class KafkaWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final KafkaTemplate<Object, Object> kafkaTemplate; + + private final String topic; + + public KafkaWebSocketMessageSender(WebSocketSessionManager sessionManager, + KafkaTemplate<Object, Object> kafkaTemplate, + String topic) { + super(sessionManager); + this.kafkaTemplate = kafkaTemplate; + this.topic = topic; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendKafkaMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendKafkaMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendKafkaMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 Kafka 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendKafkaMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + KafkaWebSocketMessage mqMessage = new KafkaWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + try { + kafkaTemplate.send(topic, mqMessage).get(); + } catch (InterruptedException | ExecutionException e) { + log.error("[sendKafkaMessage][发送消息({}) 到 Kafka 失败]", mqMessage, e); + } + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/local/LocalWebSocketMessageSender.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/local/LocalWebSocketMessageSender.java new file mode 100644 index 0000000..87eaa3c --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/local/LocalWebSocketMessageSender.java @@ -0,0 +1,20 @@ +package com.iailab.framework.websocket.core.sender.local; + +import com.iailab.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.WebSocketMessageSender; +import com.iailab.framework.websocket.core.session.WebSocketSessionManager; + +/** + * 本地的 {@link WebSocketMessageSender} 实现类 + * + * 注意:仅仅适合单机场景!!! + * + * @author iailab + */ +public class LocalWebSocketMessageSender extends AbstractWebSocketMessageSender { + + public LocalWebSocketMessageSender(WebSocketSessionManager sessionManager) { + super(sessionManager); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessage.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessage.java new file mode 100644 index 0000000..02abb91 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessage.java @@ -0,0 +1,37 @@ +package com.iailab.framework.websocket.core.sender.rabbitmq; + +import lombok.Data; + +import java.io.Serializable; + +/** + * RabbitMQ 广播 WebSocket 的消息 + * + * @author iailab + */ +@Data +public class RabbitMQWebSocketMessage implements Serializable { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageConsumer.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageConsumer.java new file mode 100644 index 0000000..9505bfb --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageConsumer.java @@ -0,0 +1,39 @@ +package com.iailab.framework.websocket.core.sender.rabbitmq; + +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.core.ExchangeTypes; +import org.springframework.amqp.rabbit.annotation.*; + +/** + * {@link RabbitMQWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author iailab + */ +@RabbitListener( + bindings = @QueueBinding( + value = @Queue( + // 在 Queue 的名字上,使用 UUID 生成其后缀。这样,启动的 Consumer 的 Queue 不同,以达到广播消费的目的 + name = "${iailab.websocket.sender-rabbitmq.queue}" + "-" + "#{T(java.util.UUID).randomUUID()}", + // Consumer 关闭时,该队列就可以被自动删除了 + autoDelete = "true" + ), + exchange = @Exchange( + name = "${iailab.websocket.sender-rabbitmq.exchange}", + type = ExchangeTypes.TOPIC, + declare = "false" + ) + ) +) +@RequiredArgsConstructor +public class RabbitMQWebSocketMessageConsumer { + + private final RabbitMQWebSocketMessageSender rabbitMQWebSocketMessageSender; + + @RabbitHandler + public void onMessage(RabbitMQWebSocketMessage message) { + rabbitMQWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageSender.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageSender.java new file mode 100644 index 0000000..f3e1409 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rabbitmq/RabbitMQWebSocketMessageSender.java @@ -0,0 +1,62 @@ +package com.iailab.framework.websocket.core.sender.rabbitmq; + +import com.iailab.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.WebSocketMessageSender; +import com.iailab.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.core.RabbitTemplate; + +/** + * 基于 RabbitMQ 的 {@link WebSocketMessageSender} 实现类 + * + * @author iailab + */ +@Slf4j +public class RabbitMQWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final RabbitTemplate rabbitTemplate; + + private final TopicExchange topicExchange; + + public RabbitMQWebSocketMessageSender(WebSocketSessionManager sessionManager, + RabbitTemplate rabbitTemplate, + TopicExchange topicExchange) { + super(sessionManager); + this.rabbitTemplate = rabbitTemplate; + this.topicExchange = topicExchange; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendRabbitMQMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendRabbitMQMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendRabbitMQMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 RabbitMQ 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendRabbitMQMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + RabbitMQWebSocketMessage mqMessage = new RabbitMQWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + rabbitTemplate.convertAndSend(topicExchange.getName(), null, mqMessage); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessage.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessage.java new file mode 100644 index 0000000..dddf1ef --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessage.java @@ -0,0 +1,34 @@ +package com.iailab.framework.websocket.core.sender.redis; + +import com.iailab.framework.mq.redis.core.pubsub.AbstractRedisChannelMessage; +import lombok.Data; + +/** + * Redis 广播 WebSocket 的消息 + */ +@Data +public class RedisWebSocketMessage extends AbstractRedisChannelMessage { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageConsumer.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageConsumer.java new file mode 100644 index 0000000..228964c --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageConsumer.java @@ -0,0 +1,23 @@ +package com.iailab.framework.websocket.core.sender.redis; + +import com.iailab.framework.mq.redis.core.pubsub.AbstractRedisChannelMessageListener; +import lombok.RequiredArgsConstructor; + +/** + * {@link RedisWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author iailab + */ +@RequiredArgsConstructor +public class RedisWebSocketMessageConsumer extends AbstractRedisChannelMessageListener<RedisWebSocketMessage> { + + private final RedisWebSocketMessageSender redisWebSocketMessageSender; + + @Override + public void onMessage(RedisWebSocketMessage message) { + redisWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageSender.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageSender.java new file mode 100644 index 0000000..5d400a1 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/redis/RedisWebSocketMessageSender.java @@ -0,0 +1,57 @@ +package com.iailab.framework.websocket.core.sender.redis; + +import com.iailab.framework.mq.redis.core.RedisMQTemplate; +import com.iailab.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.WebSocketMessageSender; +import com.iailab.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; + +/** + * 基于 Redis 的 {@link WebSocketMessageSender} 实现类 + * + * @author iailab + */ +@Slf4j +public class RedisWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final RedisMQTemplate redisMQTemplate; + + public RedisWebSocketMessageSender(WebSocketSessionManager sessionManager, + RedisMQTemplate redisMQTemplate) { + super(sessionManager); + this.redisMQTemplate = redisMQTemplate; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendRedisMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendRedisMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendRedisMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 Redis 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendRedisMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + RedisWebSocketMessage mqMessage = new RedisWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + redisMQTemplate.send(mqMessage); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessage.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessage.java new file mode 100644 index 0000000..92dc515 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessage.java @@ -0,0 +1,35 @@ +package com.iailab.framework.websocket.core.sender.rocketmq; + +import lombok.Data; + +/** + * RocketMQ 广播 WebSocket 的消息 + * + * @author iailab + */ +@Data +public class RocketMQWebSocketMessage { + + /** + * Session 编号 + */ + private String sessionId; + /** + * 用户类型 + */ + private Integer userType; + /** + * 用户编号 + */ + private Long userId; + + /** + * 消息类型 + */ + private String messageType; + /** + * 消息内容 + */ + private String messageContent; + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageConsumer.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageConsumer.java new file mode 100644 index 0000000..38b7836 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageConsumer.java @@ -0,0 +1,30 @@ +package com.iailab.framework.websocket.core.sender.rocketmq; + +import lombok.RequiredArgsConstructor; +import org.apache.rocketmq.spring.annotation.MessageModel; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; + +/** + * {@link RocketMQWebSocketMessage} 广播消息的消费者,真正把消息发送出去 + * + * @author iailab + */ +@RocketMQMessageListener( // 重点:添加 @RocketMQMessageListener 注解,声明消费的 topic + topic = "${iailab.websocket.sender-rocketmq.topic}", + consumerGroup = "${iailab.websocket.sender-rocketmq.consumer-group}", + messageModel = MessageModel.BROADCASTING // 设置为广播模式,保证每个实例都能收到消息 +) +@RequiredArgsConstructor +public class RocketMQWebSocketMessageConsumer implements RocketMQListener<RocketMQWebSocketMessage> { + + private final RocketMQWebSocketMessageSender rocketMQWebSocketMessageSender; + + @Override + public void onMessage(RocketMQWebSocketMessage message) { + rocketMQWebSocketMessageSender.send(message.getSessionId(), + message.getUserType(), message.getUserId(), + message.getMessageType(), message.getMessageContent()); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageSender.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageSender.java new file mode 100644 index 0000000..2241b13 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/sender/rocketmq/RocketMQWebSocketMessageSender.java @@ -0,0 +1,61 @@ +package com.iailab.framework.websocket.core.sender.rocketmq; + +import com.iailab.framework.websocket.core.sender.AbstractWebSocketMessageSender; +import com.iailab.framework.websocket.core.sender.WebSocketMessageSender; +import com.iailab.framework.websocket.core.session.WebSocketSessionManager; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.core.RocketMQTemplate; + +/** + * 基于 RocketMQ 的 {@link WebSocketMessageSender} 实现类 + * + * @author iailab + */ +@Slf4j +public class RocketMQWebSocketMessageSender extends AbstractWebSocketMessageSender { + + private final RocketMQTemplate rocketMQTemplate; + + private final String topic; + + public RocketMQWebSocketMessageSender(WebSocketSessionManager sessionManager, + RocketMQTemplate rocketMQTemplate, + String topic) { + super(sessionManager); + this.rocketMQTemplate = rocketMQTemplate; + this.topic = topic; + } + + @Override + public void send(Integer userType, Long userId, String messageType, String messageContent) { + sendRocketMQMessage(null, userId, userType, messageType, messageContent); + } + + @Override + public void send(Integer userType, String messageType, String messageContent) { + sendRocketMQMessage(null, null, userType, messageType, messageContent); + } + + @Override + public void send(String sessionId, String messageType, String messageContent) { + sendRocketMQMessage(sessionId, null, null, messageType, messageContent); + } + + /** + * 通过 RocketMQ 广播消息 + * + * @param sessionId Session 编号 + * @param userId 用户编号 + * @param userType 用户类型 + * @param messageType 消息类型 + * @param messageContent 消息内容 + */ + private void sendRocketMQMessage(String sessionId, Long userId, Integer userType, + String messageType, String messageContent) { + RocketMQWebSocketMessage mqMessage = new RocketMQWebSocketMessage() + .setSessionId(sessionId).setUserId(userId).setUserType(userType) + .setMessageType(messageType).setMessageContent(messageContent); + rocketMQTemplate.syncSend(topic, mqMessage); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java new file mode 100644 index 0000000..9836e56 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionHandlerDecorator.java @@ -0,0 +1,49 @@ +package com.iailab.framework.websocket.core.session; + +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.ConcurrentWebSocketSessionDecorator; +import org.springframework.web.socket.handler.WebSocketHandlerDecorator; + +/** + * {@link WebSocketHandler} 的装饰类,实现了以下功能: + * + * 1. {@link WebSocketSession} 连接或关闭时,使用 {@link #sessionManager} 进行管理 + * 2. 封装 {@link WebSocketSession} 支持并发操作 + * + * @author iailab + */ +public class WebSocketSessionHandlerDecorator extends WebSocketHandlerDecorator { + + /** + * 发送时间的限制,单位:毫秒 + */ + private static final Integer SEND_TIME_LIMIT = 1000 * 5; + /** + * 发送消息缓冲上线,单位:bytes + */ + private static final Integer BUFFER_SIZE_LIMIT = 1024 * 100; + + private final WebSocketSessionManager sessionManager; + + public WebSocketSessionHandlerDecorator(WebSocketHandler delegate, + WebSocketSessionManager sessionManager) { + super(delegate); + this.sessionManager = sessionManager; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + // 实现 session 支持并发,可参考 https://blog.csdn.net/abu935009066/article/details/131218149 + session = new ConcurrentWebSocketSessionDecorator(session, SEND_TIME_LIMIT, BUFFER_SIZE_LIMIT); + // 添加到 WebSocketSessionManager 中 + sessionManager.addSession(session); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) { + sessionManager.removeSession(session); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManager.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManager.java new file mode 100644 index 0000000..ad2608a --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManager.java @@ -0,0 +1,53 @@ +package com.iailab.framework.websocket.core.session; + +import org.springframework.web.socket.WebSocketSession; + +import java.util.Collection; + +/** + * {@link WebSocketSession} 管理器的接口 + * + * @author iailab + */ +public interface WebSocketSessionManager { + + /** + * 添加 Session + * + * @param session Session + */ + void addSession(WebSocketSession session); + + /** + * 移除 Session + * + * @param session Session + */ + void removeSession(WebSocketSession session); + + /** + * 获得指定编号的 Session + * + * @param id Session 编号 + * @return Session + */ + WebSocketSession getSession(String id); + + /** + * 获得指定用户类型的 Session 列表 + * + * @param userType 用户类型 + * @return Session 列表 + */ + Collection<WebSocketSession> getSessionList(Integer userType); + + /** + * 获得指定用户编号的 Session 列表 + * + * @param userType 用户类型 + * @param userId 用户编号 + * @return Session 列表 + */ + Collection<WebSocketSession> getSessionList(Integer userType, Long userId); + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManagerImpl.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManagerImpl.java new file mode 100644 index 0000000..418ec88 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/session/WebSocketSessionManagerImpl.java @@ -0,0 +1,125 @@ +package com.iailab.framework.websocket.core.session; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.security.core.LoginUser; +import com.iailab.framework.tenant.core.context.TenantContextHolder; +import com.iailab.framework.websocket.core.util.WebSocketFrameworkUtils; +import org.springframework.web.socket.WebSocketSession; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 默认的 {@link WebSocketSessionManager} 实现类 + * + * @author iailab + */ +public class WebSocketSessionManagerImpl implements WebSocketSessionManager { + + /** + * id 与 WebSocketSession 映射 + * + * key:Session 编号 + */ + private final ConcurrentMap<String, WebSocketSession> idSessions = new ConcurrentHashMap<>(); + + /** + * user 与 WebSocketSession 映射 + * + * key1:用户类型 + * key2:用户编号 + */ + private final ConcurrentMap<Integer, ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>>> userSessions + = new ConcurrentHashMap<>(); + + @Override + public void addSession(WebSocketSession session) { + // 添加到 idSessions 中 + idSessions.put(session.getId(), session); + // 添加到 userSessions 中 + LoginUser user = WebSocketFrameworkUtils.getLoginUser(session); + if (user == null) { + return; + } + ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>> userSessionsMap = userSessions.get(user.getUserType()); + if (userSessionsMap == null) { + userSessionsMap = new ConcurrentHashMap<>(); + if (userSessions.putIfAbsent(user.getUserType(), userSessionsMap) != null) { + userSessionsMap = userSessions.get(user.getUserType()); + } + } + CopyOnWriteArrayList<WebSocketSession> sessions = userSessionsMap.get(user.getId()); + if (sessions == null) { + sessions = new CopyOnWriteArrayList<>(); + if (userSessionsMap.putIfAbsent(user.getId(), sessions) != null) { + sessions = userSessionsMap.get(user.getId()); + } + } + sessions.add(session); + } + + @Override + public void removeSession(WebSocketSession session) { + // 移除从 idSessions 中 + idSessions.remove(session.getId()); + // 移除从 idSessions 中 + LoginUser user = WebSocketFrameworkUtils.getLoginUser(session); + if (user == null) { + return; + } + ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>> userSessionsMap = userSessions.get(user.getUserType()); + if (userSessionsMap == null) { + return; + } + CopyOnWriteArrayList<WebSocketSession> sessions = userSessionsMap.get(user.getId()); + sessions.removeIf(session0 -> session0.getId().equals(session.getId())); + if (CollUtil.isEmpty(sessions)) { + userSessionsMap.remove(user.getId(), sessions); + } + } + + @Override + public WebSocketSession getSession(String id) { + return idSessions.get(id); + } + + @Override + public Collection<WebSocketSession> getSessionList(Integer userType) { + ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>> userSessionsMap = userSessions.get(userType); + if (CollUtil.isEmpty(userSessionsMap)) { + return new ArrayList<>(); + } + LinkedList<WebSocketSession> result = new LinkedList<>(); // 避免扩容 + Long contextTenantId = TenantContextHolder.getTenantId(); + for (List<WebSocketSession> sessions : userSessionsMap.values()) { + if (CollUtil.isEmpty(sessions)) { + continue; + } + // 特殊:如果租户不匹配,则直接排除 + if (contextTenantId != null) { + Long userTenantId = WebSocketFrameworkUtils.getTenantId(sessions.get(0)); + if (!contextTenantId.equals(userTenantId)) { + continue; + } + } + result.addAll(sessions); + } + return result; + } + + @Override + public Collection<WebSocketSession> getSessionList(Integer userType, Long userId) { + ConcurrentMap<Long, CopyOnWriteArrayList<WebSocketSession>> userSessionsMap = userSessions.get(userType); + if (CollUtil.isEmpty(userSessionsMap)) { + return new ArrayList<>(); + } + CopyOnWriteArrayList<WebSocketSession> sessions = userSessionsMap.get(userId); + return CollUtil.isNotEmpty(sessions) ? new ArrayList<>(sessions) : new ArrayList<>(); + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/util/WebSocketFrameworkUtils.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/util/WebSocketFrameworkUtils.java new file mode 100644 index 0000000..96a6c45 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/core/util/WebSocketFrameworkUtils.java @@ -0,0 +1,67 @@ +package com.iailab.framework.websocket.core.util; + +import com.iailab.framework.security.core.LoginUser; +import org.springframework.web.socket.WebSocketSession; + +import java.util.Map; + +/** + * 专属于 web 包的工具类 + * + * @author iailab + */ +public class WebSocketFrameworkUtils { + + public static final String ATTRIBUTE_LOGIN_USER = "LOGIN_USER"; + + /** + * 设置当前用户 + * + * @param loginUser 登录用户 + * @param attributes Session + */ + public static void setLoginUser(LoginUser loginUser, Map<String, Object> attributes) { + attributes.put(ATTRIBUTE_LOGIN_USER, loginUser); + } + + /** + * 获取当前用户 + * + * @return 当前用户 + */ + public static LoginUser getLoginUser(WebSocketSession session) { + return (LoginUser) session.getAttributes().get(ATTRIBUTE_LOGIN_USER); + } + + /** + * 获得当前用户的编号 + * + * @return 用户编号 + */ + public static Long getLoginUserId(WebSocketSession session) { + LoginUser loginUser = getLoginUser(session); + return loginUser != null ? loginUser.getId() : null; + } + + /** + * 获得当前用户的类型 + * + * @return 用户编号 + */ + public static Integer getLoginUserType(WebSocketSession session) { + LoginUser loginUser = getLoginUser(session); + return loginUser != null ? loginUser.getUserType() : null; + } + + /** + * 获得当前用户的租户编号 + * + * @param session Session + * @return 租户编号 + */ + public static Long getTenantId(WebSocketSession session) { + LoginUser loginUser = getLoginUser(session); + return loginUser != null ? loginUser.getTenantId() : null; + } + +} diff --git a/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/package-info.java b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/package-info.java new file mode 100644 index 0000000..e10f7ce --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/java/com/iailab/framework/websocket/package-info.java @@ -0,0 +1,4 @@ +/** + * WebSocket 框架,支持多节点的广播 + */ +package com.iailab.framework.websocket; diff --git a/iailab-framework/iailab-common-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/iailab-framework/iailab-common-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..08c8131 --- /dev/null +++ b/iailab-framework/iailab-common-websocket/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.iailab.framework.websocket.config.IailabWebSocketAutoConfiguration \ No newline at end of file diff --git a/iailab-framework/iailab-common/pom.xml b/iailab-framework/iailab-common/pom.xml new file mode 100644 index 0000000..ad63de0 --- /dev/null +++ b/iailab-framework/iailab-common/pom.xml @@ -0,0 +1,159 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <groupId>com.iailab</groupId> + <artifactId>iailab-framework</artifactId> + <version>${revision}</version> + </parent> + <modelVersion>4.0.0</modelVersion> + <artifactId>iailab-common</artifactId> + <packaging>jar</packaging> + + <name>${project.artifactId}</name> + <description>定义基础 pojo 类、枚举、工具类等等</description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <dependencies> + <!-- Spring 核心 --> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-core</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-expression</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-aop</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + <dependency> + <groupId>org.aspectj</groupId> + <artifactId>aspectjweaver</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + + <dependency> + <!-- 用于生成自定义的 Spring @ConfigurationProperties 配置类的说明文件 --> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <optional>true</optional> + </dependency> + + <!-- Web 相关 --> + <dependency> + <groupId>org.springframework</groupId> + <artifactId>spring-web</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + + <dependency> + <groupId>jakarta.servlet</groupId> + <artifactId>jakarta.servlet-api</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + + <dependency> + <groupId>org.springdoc</groupId> + <artifactId>springdoc-openapi-ui</artifactId> + <scope>provided</scope> + </dependency> + + <!-- 监控相关 --> + <dependency> + <groupId>org.apache.skywalking</groupId> + <artifactId>apm-toolkit-trace</artifactId> + </dependency> + + <!-- 工具类相关 --> + <dependency> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + </dependency> + + <dependency> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct</artifactId> + </dependency> + <dependency> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct-jdk8</artifactId> <!-- use mapstruct-jdk8 for Java 8 or higher --> + </dependency> + <dependency> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct-processor</artifactId> + </dependency> + + <dependency> + <groupId>com.google.guava</groupId> + <artifactId>guava</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-databind</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.core</groupId> + <artifactId>jackson-core</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + <dependency> + <groupId>com.fasterxml.jackson.datatype</groupId> + <artifactId>jackson-datatype-jsr310</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,只有工具类需要使用到 –>--> + </dependency> + + <dependency> + <groupId>jakarta.validation</groupId> + <artifactId>jakarta.validation-api</artifactId> +<!-- <scope>provided</scope> <!– 设置为 provided,主要是 PageParam 使用到 –>--> + </dependency> + + <dependency> + <groupId>cn.hutool</groupId> + <artifactId>hutool-all</artifactId> + </dependency> + + <dependency> + <groupId>joda-time</groupId> + <artifactId>joda-time</artifactId> + </dependency> + + <dependency> + <groupId>com.alibaba</groupId> + <artifactId>transmittable-thread-local</artifactId> + </dependency> + + <dependency> + <groupId>org.jsoup</groupId> + <artifactId>jsoup</artifactId> + </dependency> + + <dependency> + <groupId>com.fhs-opensource</groupId> <!-- VO 数据翻译 --> + <artifactId>easy-trans-anno</artifactId> <!-- 默认引入的原因,方便 xxx-module-api 包使用 --> + </dependency> + + <!-- Test 测试相关 --> + <dependency> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-starter-test</artifactId> + <scope>test</scope> + </dependency> + </dependencies> + +</project> diff --git a/iailab-framework/iailab-common/src/main/java/com/fhs/trans/service/AutoTransable.java b/iailab-framework/iailab-common/src/main/java/com/fhs/trans/service/AutoTransable.java new file mode 100644 index 0000000..b00d45e --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/fhs/trans/service/AutoTransable.java @@ -0,0 +1,59 @@ +package com.fhs.trans.service; + +import com.fhs.core.trans.vo.VO; + +import java.util.ArrayList; +import java.util.List; + +/** + * 只有实现了这个接口的才能自动翻译 + * + * 为什么要赋值粘贴到 iailab-common 包下? + * 因为 AutoTransable 属于 easy-trans-service 下,无法方便的在 iailab-module-xxx-api 模块下使用 + * + * @author jackwang + * @since 2020-05-19 10:26:15 + */ +public interface AutoTransable<V extends VO> { + + /** + * 根据 ids 查询数据列表 + * + * 改方法已过期啦,请使用 selectByIds + * + * @param ids 编号数组 + * @return 数据列表 + */ + @Deprecated + default List<V> findByIds(List<? extends Object> ids){ + return new ArrayList<>(); + } + + /** + * 根据 ids 查询 + * + * @param ids 编号数组 + * @return 数据列表 + */ + default List<V> selectByIds(List<? extends Object> ids){ + return this.findByIds(ids); + } + + /** + * 获取 db 中所有的数据 + * + * @return db 中所有的数据 + */ + default List<V> select(){ + return new ArrayList<>(); + } + + /** + * 根据 id 获取 vo + * + * @param primaryValue id + * @return vo + */ + V selectById(Object primaryValue); + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoBpm.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoBpm.java new file mode 100644 index 0000000..33abe2f --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoBpm.java @@ -0,0 +1,16 @@ +package com.iailab.framework.common.annotation; + +import java.lang.annotation.*; + +/** + * 业务流程 + * + * @author PanZhibao + * @Description + * @createTime 2022年12月20日 16:10:00 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AutoBpm { +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoDict.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoDict.java new file mode 100644 index 0000000..7205fb9 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoDict.java @@ -0,0 +1,20 @@ +package com.iailab.framework.common.annotation; + +import java.lang.annotation.*; + +/** + * @author PanZhibao + * @Description + * @createTime 2022年05月21日 10:59:00 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AutoDict { + + /** + * 暂时无用 + * @return + */ + String value() default ""; +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoUser.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoUser.java new file mode 100644 index 0000000..c1c0a36 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/AutoUser.java @@ -0,0 +1,20 @@ +package com.iailab.framework.common.annotation; + +import java.lang.annotation.*; + +/** + * @author PanZhibao + * @Description + * @createTime 2023年06月07日 11:35:00 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AutoUser { + + /** + * 暂时无用 + * @return + */ + String value() default ""; +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmProcess.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmProcess.java new file mode 100644 index 0000000..8453bef --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmProcess.java @@ -0,0 +1,25 @@ +package com.iailab.framework.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 业务流程ID + * + * @author PanZhibao + * @Description + * @createTime 2022年12月20日 16:17:00 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface BpmProcess { + + /** + * 业务ID + * + * @return + */ + String businessKey(); +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmStatus.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmStatus.java new file mode 100644 index 0000000..213f0a7 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/BpmStatus.java @@ -0,0 +1,25 @@ +package com.iailab.framework.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 业务流程状态 + * + * @author PanZhibao + * @Description + * @createTime 2022年12月20日 16:14:00 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface BpmStatus { + + /** + * 业务ID + * + * @return + */ + String businessKey(); +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/Dict.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/Dict.java new file mode 100644 index 0000000..dd01bb5 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/Dict.java @@ -0,0 +1,32 @@ +package com.iailab.framework.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 字典注解 + * + * @author PanZhibao + * @Description + * @createTime 2022年05月20日 17:36:00 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Dict { + + /** + * 数据code + * + * @return + */ + String dicCode(); + + /** + * 数据itemValue + * + * @return + */ + String itemValue(); +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/UserRealName.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/UserRealName.java new file mode 100644 index 0000000..a142543 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/annotation/UserRealName.java @@ -0,0 +1,30 @@ +package com.iailab.framework.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * @author PanZhibao + * @Description + * @createTime 2023年06月07日 11:37:00 + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface UserRealName { + + /** + * 用户ID + * + * @return + */ + String userid() default ""; + + /** + * 用户账号 + * + * @return + */ + String username() default ""; +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CacheConstant.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CacheConstant.java new file mode 100644 index 0000000..36e9ba2 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CacheConstant.java @@ -0,0 +1,108 @@ +package com.iailab.framework.common.constant; + +/** + * @author: huangxutao + * @date: 2019-06-14 + * @description: 缓存常量 + */ +public interface CacheConstant { + + /** + * 字典信息缓存(含禁用的字典项) + */ + public static final String SYS_DICT_CACHE = "sys:cache:dict"; + + /** + * 字典信息缓存 status为有效的 + */ + public static final String SYS_ENABLE_DICT_CACHE = "sys:cache:dictEnable"; + /** + * 表字典信息缓存 + */ + public static final String SYS_DICT_TABLE_CACHE = "sys:cache:dictTable"; + public static final String SYS_DICT_TABLE_BY_KEYS_CACHE = SYS_DICT_TABLE_CACHE + "ByKeys"; + + /** + * 数据权限配置缓存 + */ + public static final String SYS_DATA_PERMISSIONS_CACHE = "sys:cache:permission:datarules"; + + /** + * 缓存用户信息 + */ + public static final String SYS_USERS_CACHE = "sys:cache:user"; + + /** + * 全部部门信息缓存 + */ + public static final String SYS_DEPARTS_CACHE = "sys:cache:depart:alldata"; + + + /** + * 全部部门ids缓存 + */ + public static final String SYS_DEPART_IDS_CACHE = "sys:cache:depart:allids"; + + + /** + * 测试缓存key + */ + public static final String TEST_DEMO_CACHE = "test:demo"; + + /** + * 字典信息缓存 + */ + public static final String SYS_DYNAMICDB_CACHE = "sys:cache:dbconnect:dynamic:"; + + /** + * gateway路由缓存 + */ + public static final String GATEWAY_ROUTES = "sys:cache:cloud:gateway_routes"; + + + /** + * gatewayAPI缓存 + */ + public static final String GATEWAY_APIS = "sys:cache:cloud:gateway_apis"; + + /** + * gateway路由 reload key + */ + public static final String ROUTE_JVM_RELOAD_TOPIC = "gateway_jvm_route_reload_topic"; + + /** + * TODO 冗余代码 待删除 + *插件商城排行榜 + */ + public static final String PLUGIN_MALL_RANKING = "pluginMall::rankingList"; + /** + * TODO 冗余代码 待删除 + *插件商城排行榜 + */ + public static final String PLUGIN_MALL_PAGE_LIST = "pluginMall::queryPageList"; + + + /** + * online列表页配置信息缓存key + */ + public static final String ONLINE_LIST = "sys:cache:online:list"; + + /** + * online表单页配置信息缓存key + */ + public static final String ONLINE_FORM = "sys:cache:online:form"; + + /** + * online报表 + */ + public static final String ONLINE_RP = "sys:cache:online:rp"; + + /** + * online图表 + */ + public static final String ONLINE_GRAPH = "sys:cache:online:graph"; + /** + * 拖拽页面信息缓存 + */ + public static final String DRAG_PAGE_CACHE = "drag:cache:param"; +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CommonConstant.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CommonConstant.java new file mode 100644 index 0000000..5fb8fca --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/CommonConstant.java @@ -0,0 +1,406 @@ +package com.iailab.framework.common.constant; + +import java.math.BigDecimal; + +/** + * @Description: 通用常量 + */ +public interface CommonConstant { + + BigDecimal BAD_VALUE = new BigDecimal("-2"); + + BigDecimal ZERO_VALUE = new BigDecimal("0"); + + /** + * 正常状态 + */ + public static final Integer STATUS_NORMAL = 0; + + /** + * 禁用状态 + */ + public static final Integer STATUS_DISABLE = -1; + + /** + * 删除标志 + */ + public static final Integer DEL_FLAG_1 = 1; + + /** + * 未删除 + */ + public static final Integer DEL_FLAG_0 = 0; + + /** + * 未提交 + */ + public static final Integer SUBMINT_STATUS_0 = 0; + + /** + * 系统日志类型: 登录 + */ + public static final int LOG_TYPE_1 = 1; + + /** + * 系统日志类型: 操作 + */ + public static final int LOG_TYPE_2 = 2; + + /** + * 操作日志类型: 查询 + */ + public static final int OPERATE_TYPE_1 = 1; + + /** + * 操作日志类型: 添加 + */ + public static final int OPERATE_TYPE_2 = 2; + + /** + * 操作日志类型: 更新 + */ + public static final int OPERATE_TYPE_3 = 3; + + /** + * 操作日志类型: 删除 + */ + public static final int OPERATE_TYPE_4 = 4; + + /** + * 操作日志类型: 倒入 + */ + public static final int OPERATE_TYPE_5 = 5; + + /** + * 操作日志类型: 导出 + */ + public static final int OPERATE_TYPE_6 = 6; + + /** + * 提交 + */ + public static final int SUBMIT_FLAG_1 = 1; + + /** + * 提交 + */ + public static final int SUBMIT_FLAG_0 = 0; + + /** + * 启用 + */ + public static final int IS_ENABLE = 1; + + /** + * 常量点类型 + */ + public static final String POINT_TYPE_NAME_CONSTANT = "CONSTANT"; + + + /** {@code 500 Server Error} (HTTP/1.0 - RFC 1945) */ + public static final Integer SC_INTERNAL_SERVER_ERROR_500 = 500; + /** {@code 200 OK} (HTTP/1.0 - RFC 1945) */ + public static final Integer SC_OK_200 = 200; + + /**访问权限认证未通过 510*/ + public static final Integer SC_JEECG_NO_AUTHZ=510; + + /** 登录用户Shiro权限缓存KEY前缀 */ + public static String PREFIX_USER_SHIRO_CACHE = "shiro:cache:org.jeecg.config.shiro.ShiroRealm.authorizationCache:"; + /** 登录用户Token令牌缓存KEY前缀 */ + public static final String PREFIX_USER_TOKEN = "prefix_user_token_"; +// /** Token缓存时间:3600秒即一小时 */ +// public static final int TOKEN_EXPIRE_TIME = 3600; + + /** 登录二维码 */ + public static final String LOGIN_QRCODE_PRE = "QRCODELOGIN:"; + public static final String LOGIN_QRCODE = "LQ:"; + /** 登录二维码token */ + public static final String LOGIN_QRCODE_TOKEN = "LQT:"; + + + /** + * 0:一级菜单 + */ + public static final Integer MENU_TYPE_0 = 0; + /** + * 1:子菜单 + */ + public static final Integer MENU_TYPE_1 = 1; + /** + * 2:按钮权限 + */ + public static final Integer MENU_TYPE_2 = 2; + + /**通告对象类型(USER:指定用户,ALL:全体用户)*/ + public static final String MSG_TYPE_UESR = "USER"; + public static final String MSG_TYPE_ALL = "ALL"; + + /**发布状态(0未发布,1已发布,2已撤销)*/ + public static final String NO_SEND = "0"; + public static final String HAS_SEND = "1"; + public static final String HAS_CANCLE = "2"; + + /**阅读状态(0未读,1已读)*/ + public static final String HAS_READ_FLAG = "1"; + public static final String NO_READ_FLAG = "0"; + + /**优先级(L低,M中,H高)*/ + public static final String PRIORITY_L = "L"; + public static final String PRIORITY_M = "M"; + public static final String PRIORITY_H = "H"; + + /** + * 短信模板方式 0 .登录模板、1.注册模板、2.忘记密码模板 + */ + public static final String SMS_TPL_TYPE_0 = "0"; + public static final String SMS_TPL_TYPE_1 = "1"; + public static final String SMS_TPL_TYPE_2 = "2"; + + /** + * 状态(0无效1有效) + */ + public static final String STATUS_0 = "0"; + public static final String STATUS_1 = "1"; + + /** + * 同步工作流引擎1同步0不同步 + */ + public static final Integer ACT_SYNC_1 = 1; + public static final Integer ACT_SYNC_0 = 0; + + /** + * 消息类型1:通知公告2:系统消息 + */ + public static final String MSG_CATEGORY_1 = "1"; + public static final String MSG_CATEGORY_2 = "2"; + + /** + * 是否配置菜单的数据权限 1是0否 + */ + public static final Integer RULE_FLAG_0 = 0; + public static final Integer RULE_FLAG_1 = 1; + + /** + * 是否用户已被冻结 1正常(解冻) 2冻结 + */ + public static final Integer USER_UNFREEZE = 1; + public static final Integer USER_FREEZE = 2; + + /**字典翻译文本后缀*/ + public static final String DICT_TEXT_SUFFIX = "_dictText"; + + /** + * 表单设计器主表类型 + */ + public static final Integer DESIGN_FORM_TYPE_MAIN = 1; + + /** + * 表单设计器子表表类型 + */ + public static final Integer DESIGN_FORM_TYPE_SUB = 2; + + /** + * 表单设计器URL授权通过 + */ + public static final Integer DESIGN_FORM_URL_STATUS_PASSED = 1; + + /** + * 表单设计器URL授权未通过 + */ + public static final Integer DESIGN_FORM_URL_STATUS_NOT_PASSED = 2; + + /** + * 表单设计器新增 Flag + */ + public static final String DESIGN_FORM_URL_TYPE_ADD = "add"; + /** + * 表单设计器修改 Flag + */ + public static final String DESIGN_FORM_URL_TYPE_EDIT = "edit"; + /** + * 表单设计器详情 Flag + */ + public static final String DESIGN_FORM_URL_TYPE_DETAIL = "detail"; + /** + * 表单设计器复用数据 Flag + */ + public static final String DESIGN_FORM_URL_TYPE_REUSE = "reuse"; + /** + * 表单设计器编辑 Flag (已弃用) + */ + public static final String DESIGN_FORM_URL_TYPE_VIEW = "view"; + + /** + * online参数值设置(是:Y, 否:N) + */ + public static final String ONLINE_PARAM_VAL_IS_TURE = "Y"; + public static final String ONLINE_PARAM_VAL_IS_FALSE = "N"; + + /** + * 文件上传类型(本地:local,Minio:minio,阿里云:alioss) + */ + public static final String UPLOAD_TYPE_LOCAL = "local"; + public static final String UPLOAD_TYPE_MINIO = "minio"; + public static final String UPLOAD_TYPE_OSS = "alioss"; + + /** + * 文档上传自定义桶名称 + */ + public static final String UPLOAD_CUSTOM_BUCKET = "eoafile"; + /** + * 文档上传自定义路径 + */ + public static final String UPLOAD_CUSTOM_PATH = "eoafile"; + /** + * 文件外链接有效天数 + */ + public static final Integer UPLOAD_EFFECTIVE_DAYS = 1; + + /** + * 员工身份 (1:普通员工 2:上级) + */ + public static final Integer USER_IDENTITY_1 = 1; + public static final Integer USER_IDENTITY_2 = 2; + + /** sys_user 表 username 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_SYS_USER_USERNAME = "uniq_sys_user_username"; + /** sys_user 表 work_no 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_SYS_USER_WORK_NO = "uniq_sys_user_work_no"; + /** sys_user 表 phone 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_SYS_USER_PHONE = "uniq_sys_user_phone"; + /** 达梦数据库升提示。违反表[SYS_USER]唯一性约束 */ + public static final String SQL_INDEX_UNIQ_SYS_USER = "唯一性约束"; + + /** sys_user 表 email 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_SYS_USER_EMAIL = "uniq_sys_user_email"; + /** sys_quartz_job 表 job_class_name 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_JOB_CLASS_NAME = "uniq_job_class_name"; + /** sys_position 表 code 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_CODE = "uniq_code"; + /** sys_role 表 code 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_SYS_ROLE_CODE = "uniq_sys_role_role_code"; + /** sys_depart 表 code 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_DEPART_ORG_CODE = "uniq_depart_org_code"; + /** sys_category 表 code 唯一键索引 */ + public static final String SQL_INDEX_UNIQ_CATEGORY_CODE = "idx_sc_code"; + /** + * 在线聊天 是否为默认分组 + */ + public static final String IM_DEFAULT_GROUP = "1"; + /** + * 在线聊天 图片文件保存路径 + */ + public static final String IM_UPLOAD_CUSTOM_PATH = "imfile"; + /** + * 在线聊天 用户状态 + */ + public static final String IM_STATUS_ONLINE = "online"; + + /** + * 在线聊天 SOCKET消息类型 + */ + public static final String IM_SOCKET_TYPE = "chatMessage"; + + /** + * 在线聊天 是否开启默认添加好友 1是 0否 + */ + public static final String IM_DEFAULT_ADD_FRIEND = "1"; + + /** + * 在线聊天 用户好友缓存前缀 + */ + public static final String IM_PREFIX_USER_FRIEND_CACHE = "sys:cache:im:im_prefix_user_friend_"; + + /** + * 考勤补卡业务状态 (1:同意 2:不同意) + */ + public static final String SIGN_PATCH_BIZ_STATUS_1 = "1"; + public static final String SIGN_PATCH_BIZ_STATUS_2 = "2"; + + /** + * 公文文档上传自定义路径 + */ + public static final String UPLOAD_CUSTOM_PATH_OFFICIAL = "officialdoc"; + /** + * 公文文档下载自定义路径 + */ + public static final String DOWNLOAD_CUSTOM_PATH_OFFICIAL = "officaldown"; + + /** + * WPS存储值类别(1 code文号 2 text(WPS模板还是公文发文模板)) + */ + public static final String WPS_TYPE_1="1"; + public static final String WPS_TYPE_2="2"; + + + public final static String X_ACCESS_TOKEN = "X-Access-Token"; + public final static String X_SIGN = "X-Sign"; + public final static String X_TIMESTAMP = "X-TIMESTAMP"; + public final static String TOKEN_IS_INVALID_MSG = "Token失效,请重新登录!"; + + /** + * 多租户 请求头 + */ + public final static String TENANT_ID = "tenant-id"; + + /** + * 微服务读取配置文件属性 服务地址 + */ + public final static String CLOUD_SERVER_KEY = "spring.cloud.nacos.discovery.server-addr"; + + /** + * 第三方登录 验证密码/创建用户 都需要设置一个操作码 防止被恶意调用 + */ + public final static String THIRD_LOGIN_CODE = "third_login_code"; + + /** + * 第三方APP同步方向:本地 --> 第三方APP + */ + String THIRD_SYNC_TO_APP = "SYNC_TO_APP"; + /** + * 第三方APP同步方向:第三方APP --> 本地 + */ + String THIRD_SYNC_TO_LOCAL = "SYNC_TO_LOCAL"; + + /** 系统通告消息状态:0=未发布 */ + String ANNOUNCEMENT_SEND_STATUS_0 = "0"; + /** 系统通告消息状态:1=已发布 */ + String ANNOUNCEMENT_SEND_STATUS_1 = "1"; + /** 系统通告消息状态:2=已撤销 */ + String ANNOUNCEMENT_SEND_STATUS_2 = "2"; + + /**ONLINE 报表权限用 从request中获取地址栏后的参数*/ + String ONL_REP_URL_PARAM_STR="onlRepUrlParamStr"; + + /**POST请求*/ + String HTTP_POST = "POST"; + + /**PUT请求*/ + String HTTP_PUT = "PUT"; + + /**PATCH请求*/ + String HTTP_PATCH = "PATCH"; + + /**未知的*/ + String UNKNOWN = "unknown"; + + /**字符串http*/ + String STR_HTTP = "http"; + + /**String 类型的空值*/ + String STRING_NULL = "null"; + + /**java.util.Date 包*/ + String JAVA_UTIL_DATE = "java.util.Date"; + + /**.do*/ + String SPOT_DO = ".do"; + + + /**前端vue版本标识*/ + String VERSION="X-Version"; + + /**前端vue版本*/ + String VERSION_VUE3="vue3"; +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/Constant.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/Constant.java new file mode 100644 index 0000000..0d530d5 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/Constant.java @@ -0,0 +1,161 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.constant; + +/** + * 常量 + * + * @author Mark sunlightcs@gmail.com + */ +public interface Constant { + /** + * 成功 + */ + int SUCCESS = 1; + /** + * 失败 + */ + int FAIL = 0; + /** + * 菜单根节点标识 + */ + Long MENU_ROOT = 0L; + /** + * 部门根节点标识 + */ + Long DEPT_ROOT = 0L; + /** + * 升序 + */ + String ASC = "asc"; + /** + * 降序 + */ + String DESC = "desc"; + /** + * 创建时间字段名 + */ + String CREATE_DATE = "create_date"; + + String CREATE_TIME = "create_time"; + + /** + * 数据权限过滤 + */ + String SQL_FILTER = "sqlFilter"; + /** + * 当前页码 + */ + String PAGE = "page"; + /** + * 每页显示记录数 + */ + String LIMIT = "limit"; + /** + * 排序字段 + */ + String ORDER_FIELD = "orderField"; + /** + * 排序方式 + */ + String ORDER = "order"; + /** + * token header + */ + String TOKEN_HEADER = "authorization"; + + /** + * tenantCode + */ + String TENANT_CODE = "tenantCode"; + + /** + * tenantId + */ + String TENANT_ID = "tenantId"; + + /** + * tenantId + */ + String HEAD_TENANT_ID = "tenant-id"; + + /** + * 云存储配置KEY + */ + String CLOUD_STORAGE_CONFIG_KEY = "CLOUD_STORAGE_CONFIG_KEY"; + + Integer DEL_FLAG_0 = 0; + + enum EnableStatus { + DISABLED(0), + NORMAL(1); + + private int value; + + private EnableStatus(int value) { + this.value = value; + } + + public int getValue() { + return this.value; + } + } + + /** + * 定时任务状态 + */ + enum ScheduleStatus { + /** + * 暂停 + */ + PAUSE(0), + /** + * 正常 + */ + NORMAL(1); + + private int value; + + ScheduleStatus(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } + + /** + * 云服务商 + */ + enum CloudService { + /** + * 七牛云 + */ + QINIU(1), + /** + * 阿里云 + */ + ALIYUN(2), + /** + * 腾讯云 + */ + QCLOUD(3); + + private int value; + + CloudService(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/GlobalConstants.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/GlobalConstants.java new file mode 100644 index 0000000..52bdc03 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/constant/GlobalConstants.java @@ -0,0 +1,29 @@ +package com.iailab.framework.common.constant; + +/** +* @Description: GlobalConstants +* @author: scott +* @date: 2020/01/01 16:01 +*/ +public class GlobalConstants { + + /** + * 业务处理器beanName传递参数 + */ + public static final String HANDLER_NAME = "handlerName"; + + /** + * 路由刷新触发器 + */ + public static final String LODER_ROUDER_HANDLER = "loderRouderHandler"; + + /** + * API刷新触发器 + */ + public static final String LODER_API_HANDLER = "loderApiHandler"; + + /** + * redis消息通道名称 + */ + public static final String REDIS_TOPIC_NAME="redis_topic"; +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/IntArrayValuable.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/IntArrayValuable.java new file mode 100644 index 0000000..a4ff210 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/IntArrayValuable.java @@ -0,0 +1,15 @@ +package com.iailab.framework.common.core; + +/** + * 可生成 Int 数组的接口 + * + * @author iailab + */ +public interface IntArrayValuable { + + /** + * @return int 数组 + */ + int[] array(); + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/KeyValue.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/KeyValue.java new file mode 100644 index 0000000..a0e885c --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/core/KeyValue.java @@ -0,0 +1,22 @@ +package com.iailab.framework.common.core; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * Key Value 的键值对 + * + * @author iailab + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class KeyValue<K, V> implements Serializable { + + private K key; + private V value; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/dto/TreeLabelDTO.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/dto/TreeLabelDTO.java new file mode 100644 index 0000000..cfa0e22 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/dto/TreeLabelDTO.java @@ -0,0 +1,20 @@ +package com.iailab.framework.common.dto; + +import lombok.Data; + +import java.util.List; + +/** + * @author PanZhibao + * @Description + * @createTime 2024年09月23日 + */ +@Data +public class TreeLabelDTO { + + private String value; + + private String label; + + private List<TreeLabelDTO> children; +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/CommonStatusEnum.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/CommonStatusEnum.java new file mode 100644 index 0000000..a611b8b --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/CommonStatusEnum.java @@ -0,0 +1,46 @@ +package com.iailab.framework.common.enums; + +import cn.hutool.core.util.ObjUtil; +import com.iailab.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 通用状态枚举 + * + * @author iailab + */ +@Getter +@AllArgsConstructor +public enum CommonStatusEnum implements IntArrayValuable { + + ENABLE(0, "开启"), + DISABLE(1, "关闭"); + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(CommonStatusEnum::getStatus).toArray(); + + /** + * 状态值 + */ + private final Integer status; + /** + * 状态名 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } + + public static boolean isEnable(Integer status) { + return ObjUtil.equal(ENABLE.status, status); + } + + public static boolean isDisable(Integer status) { + return ObjUtil.equal(DISABLE.status, status); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DateIntervalEnum.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DateIntervalEnum.java new file mode 100644 index 0000000..af55211 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DateIntervalEnum.java @@ -0,0 +1,46 @@ +package com.iailab.framework.common.enums; + +import cn.hutool.core.util.ArrayUtil; +import com.iailab.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 时间间隔的枚举 + * + * @author dhb52 + */ +@Getter +@AllArgsConstructor +public enum DateIntervalEnum implements IntArrayValuable { + + DAY(1, "天"), + WEEK(2, "周"), + MONTH(3, "月"), + QUARTER(4, "季度"), + YEAR(5, "年") + ; + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(DateIntervalEnum::getInterval).toArray(); + + /** + * 类型 + */ + private final Integer interval; + /** + * 名称 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } + + public static DateIntervalEnum valueOf(Integer interval) { + return ArrayUtil.firstMatch(item -> item.getInterval().equals(interval), DateIntervalEnum.values()); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DocumentEnum.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DocumentEnum.java new file mode 100644 index 0000000..01f8ef4 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/DocumentEnum.java @@ -0,0 +1,21 @@ +package com.iailab.framework.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文档地址 + * + * @author iailab + */ +@Getter +@AllArgsConstructor +public enum DocumentEnum { + + REDIS_INSTALL("https://iailab.cn", "Redis 安装文档"), + TENANT("https://iailab.cn", "SaaS 多租户文档"); + + private final String url; + private final String memo; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/ErrorCode.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/ErrorCode.java new file mode 100644 index 0000000..6228b18 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/ErrorCode.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.enums; + +/** + * 错误编码,由5位数字组成,前2位为模块编码,后3位为业务编码 + * <p> + * 如:10001(10代表系统模块,001代表业务代码) + * </p> + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public interface ErrorCode { + int INTERNAL_SERVER_ERROR = 500; + int UNAUTHORIZED = 401; + + int NOT_NULL = 10001; + int DB_RECORD_EXISTS = 10002; + int PARAMS_GET_ERROR = 10003; + int ACCOUNT_PASSWORD_ERROR = 10004; + int ACCOUNT_DISABLE = 10005; + int IDENTIFIER_NOT_NULL = 10006; + int CAPTCHA_ERROR = 10007; + int SUB_MENU_EXIST = 10008; + int PASSWORD_ERROR = 10009; + int SUPERIOR_DEPT_ERROR = 10011; + int SUPERIOR_MENU_ERROR = 10012; + int DATA_SCOPE_PARAMS_ERROR = 10013; + int DEPT_SUB_DELETE_ERROR = 10014; + int DEPT_USER_DELETE_ERROR = 10015; + int UPLOAD_FILE_EMPTY = 10019; + int TOKEN_NOT_EMPTY = 10020; + int TOKEN_INVALID = 10021; + int ACCOUNT_LOCK = 10022; + int OSS_UPLOAD_FILE_ERROR = 10024; + int REDIS_ERROR = 10027; + int JOB_ERROR = 10028; + int INVALID_SYMBOL = 10029; +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/RpcConstants.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/RpcConstants.java new file mode 100644 index 0000000..b29ee71 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/RpcConstants.java @@ -0,0 +1,17 @@ +package com.iailab.framework.common.enums; + +/** + * RPC 相关的枚举 + * + * 虽然放在 iailab-common-rpc 会相对合适,但是每个 API 模块需要使用到,所以暂时只好放在此处 + * + * @author iailab + */ +public class RpcConstants { + + /** + * RPC API 的前缀 + */ + public static final String RPC_API_PREFIX = "/rpc-api"; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/TerminalEnum.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/TerminalEnum.java new file mode 100644 index 0000000..c49ae51 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/TerminalEnum.java @@ -0,0 +1,40 @@ +package com.iailab.framework.common.enums; + +import com.iailab.framework.common.core.IntArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * 终端的枚举 + * + * @author iailab + */ +@RequiredArgsConstructor +@Getter +public enum TerminalEnum implements IntArrayValuable { + + UNKNOWN(0, "未知"), // 目的:在无法解析到 terminal 时,使用它 + WECHAT_MINI_PROGRAM(10, "微信小程序"), + WECHAT_WAP(11, "微信公众号"), + H5(20, "H5 网页"), + APP(31, "手机 App"), + ; + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(TerminalEnum::getTerminal).toArray(); + + /** + * 终端 + */ + private final Integer terminal; + /** + * 终端名 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/UserTypeEnum.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/UserTypeEnum.java new file mode 100644 index 0000000..560e02c --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/UserTypeEnum.java @@ -0,0 +1,39 @@ +package com.iailab.framework.common.enums; + +import cn.hutool.core.util.ArrayUtil; +import com.iailab.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 全局用户类型枚举 + */ +@AllArgsConstructor +@Getter +public enum UserTypeEnum implements IntArrayValuable { + + MEMBER(1, "会员"), // 面向 c 端,普通用户 + ADMIN(2, "管理员"); // 面向 b 端,管理后台 + + public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(UserTypeEnum::getValue).toArray(); + + /** + * 类型 + */ + private final Integer value; + /** + * 类型名 + */ + private final String name; + + public static UserTypeEnum valueOf(Integer value) { + return ArrayUtil.firstMatch(userType -> userType.getValue().equals(value), UserTypeEnum.values()); + } + + @Override + public int[] array() { + return ARRAYS; + } +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/WebFilterOrderEnum.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/WebFilterOrderEnum.java new file mode 100644 index 0000000..ea7e7f7 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/enums/WebFilterOrderEnum.java @@ -0,0 +1,36 @@ +package com.iailab.framework.common.enums; + +/** + * Web 过滤器顺序的枚举类,保证过滤器按照符合我们的预期 + * + * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 enum 包下 + * + * @author iailab + */ +public interface WebFilterOrderEnum { + + int CORS_FILTER = Integer.MIN_VALUE; + + int TRACE_FILTER = CORS_FILTER + 1; + + int ENV_TAG_FILTER = TRACE_FILTER + 1; + + int REQUEST_BODY_CACHE_FILTER = Integer.MIN_VALUE + 500; + + // OrderedRequestContextFilter 默认为 -105,用于国际化上下文等等 + + int TENANT_CONTEXT_FILTER = - 104; // 需要保证在 ApiAccessLogFilter 前面 + + int API_ACCESS_LOG_FILTER = -103; // 需要保证在 RequestBodyCacheFilter 后面 + + int XSS_FILTER = -102; // 需要保证在 RequestBodyCacheFilter 后面 + + // Spring Security Filter 默认为 -100,可见 org.springframework.boot.autoconfigure.security.SecurityProperties 配置属性类 + + int TENANT_SECURITY_FILTER = -99; // 需要保证在 Spring Security 过滤器后面 + + int FLOWABLE_FILTER = -98; // 需要保证在 Spring Security 过滤后面 + + int DEMO_FILTER = Integer.MAX_VALUE; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ErrorCode.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ErrorCode.java new file mode 100644 index 0000000..af64f8f --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ErrorCode.java @@ -0,0 +1,32 @@ +package com.iailab.framework.common.exception; + +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.iailab.framework.common.exception.enums.ServiceErrorCodeRange; +import lombok.Data; + +/** + * 错误码对象 + * + * 全局错误码,占用 [0, 999], 参见 {@link GlobalErrorCodeConstants} + * 业务异常错误码,占用 [1 000 000 000, +∞),参见 {@link ServiceErrorCodeRange} + * + * TODO 错误码设计成对象的原因,为未来的 i18 国际化做准备 + */ +@Data +public class ErrorCode { + + /** + * 错误码 + */ + private final Integer code; + /** + * 错误提示 + */ + private final String msg; + + public ErrorCode(Integer code, String message) { + this.code = code; + this.msg = message; + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ExceptionUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ExceptionUtils.java new file mode 100644 index 0000000..b4bd8e0 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ExceptionUtils.java @@ -0,0 +1,53 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.exception; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; + +/** + * Exception工具类 + * + * @author Mark sunlightcs@gmail.com + */ +public class ExceptionUtils { + + /** + * 获取异常信息 + * @param ex 异常 + * @return 返回异常信息 + */ + public static String getErrorStackTrace(Exception ex){ + StringWriter sw = null; + PrintWriter pw = null; + try { + sw = new StringWriter(); + pw = new PrintWriter(sw, true); + ex.printStackTrace(pw); + }finally { + try { + if(pw != null) { + pw.close(); + } + } catch (Exception e) { + + } + try { + if(sw != null) { + sw.close(); + } + } catch (IOException e) { + + } + } + + return sw.toString(); + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServerException.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServerException.java new file mode 100644 index 0000000..9e7f0f4 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServerException.java @@ -0,0 +1,60 @@ +package com.iailab.framework.common.exception; + +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 服务器异常 Exception + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class ServerException extends RuntimeException { + + /** + * 全局错误码 + * + * @see GlobalErrorCodeConstants + */ + private Integer code; + /** + * 错误提示 + */ + private String message; + + /** + * 空构造方法,避免反序列化问题 + */ + public ServerException() { + } + + public ServerException(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMsg(); + } + + public ServerException(Integer code, String message) { + this.code = code; + this.message = message; + } + + public Integer getCode() { + return code; + } + + public ServerException setCode(Integer code) { + this.code = code; + return this; + } + + @Override + public String getMessage() { + return message; + } + + public ServerException setMessage(String message) { + this.message = message; + return this; + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServiceException.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServiceException.java new file mode 100644 index 0000000..536bb40 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/ServiceException.java @@ -0,0 +1,60 @@ +package com.iailab.framework.common.exception; + +import com.iailab.framework.common.exception.enums.ServiceErrorCodeRange; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 业务逻辑异常 Exception + */ +@Data +@EqualsAndHashCode(callSuper = true) +public final class ServiceException extends RuntimeException { + + /** + * 业务错误码 + * + * @see ServiceErrorCodeRange + */ + private Integer code; + /** + * 错误提示 + */ + private String message; + + /** + * 空构造方法,避免反序列化问题 + */ + public ServiceException() { + } + + public ServiceException(ErrorCode errorCode) { + this.code = errorCode.getCode(); + this.message = errorCode.getMsg(); + } + + public ServiceException(Integer code, String message) { + this.code = code; + this.message = message; + } + + public Integer getCode() { + return code; + } + + public ServiceException setCode(Integer code) { + this.code = code; + return this; + } + + @Override + public String getMessage() { + return message; + } + + public ServiceException setMessage(String message) { + this.message = message; + return this; + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/GlobalErrorCodeConstants.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/GlobalErrorCodeConstants.java new file mode 100644 index 0000000..bf1e415 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/GlobalErrorCodeConstants.java @@ -0,0 +1,42 @@ +package com.iailab.framework.common.exception.enums; + +import com.iailab.framework.common.exception.ErrorCode; + +/** + * 全局错误码枚举 + * 0-999 系统异常编码保留 + * + * 一般情况下,使用 HTTP 响应状态码 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status + * 虽然说,HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的 + * 比较特殊的是,因为之前一直使用 0 作为成功,就不使用 200 啦。 + * + * @author iailab + */ +public interface GlobalErrorCodeConstants { + + ErrorCode SUCCESS = new ErrorCode(0, "成功"); + + // ========== 客户端错误段 ========== + + ErrorCode BAD_REQUEST = new ErrorCode(400, "请求参数不正确"); + ErrorCode UNAUTHORIZED = new ErrorCode(401, "账号未登录"); + ErrorCode FORBIDDEN = new ErrorCode(403, "没有该操作权限"); + ErrorCode NOT_FOUND = new ErrorCode(404, "请求未找到"); + ErrorCode METHOD_NOT_ALLOWED = new ErrorCode(405, "请求方法不正确"); + ErrorCode DATA_REPETITION = new ErrorCode(406, "数据库存在重复数据"); + ErrorCode LOCKED = new ErrorCode(423, "请求失败,请稍后重试"); // 并发请求,不允许 + ErrorCode TOO_MANY_REQUESTS = new ErrorCode(429, "请求过于频繁,请稍后重试"); + + // ========== 服务端错误段 ========== + + ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常"); + ErrorCode NOT_IMPLEMENTED = new ErrorCode(501, "功能未实现/未开启"); + ErrorCode ERROR_CONFIGURATION = new ErrorCode(502, "错误的配置项"); + + // ========== 自定义错误段 ========== + ErrorCode REPEATED_REQUESTS = new ErrorCode(900, "重复请求,请稍后重试"); // 重复请求 + ErrorCode DEMO_DENY = new ErrorCode(901, "演示模式,禁止写操作"); + + ErrorCode UNKNOWN = new ErrorCode(999, "未知错误"); + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/ServiceErrorCodeRange.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/ServiceErrorCodeRange.java new file mode 100644 index 0000000..237b926 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/enums/ServiceErrorCodeRange.java @@ -0,0 +1,46 @@ +package com.iailab.framework.common.exception.enums; + +/** + * 业务异常的错误码区间,解决:解决各模块错误码定义,避免重复,在此只声明不做实际使用 + * + * 一共 10 位,分成四段 + * + * 第一段,1 位,类型 + * 1 - 业务级别异常 + * x - 预留 + * 第二段,3 位,系统类型 + * 001 - 用户系统 + * 002 - 商品系统 + * 003 - 订单系统 + * 004 - 支付系统 + * 005 - 优惠劵系统 + * ... - ... + * 第三段,3 位,模块 + * 不限制规则。 + * 一般建议,每个系统里面,可能有多个模块,可以再去做分段。以用户系统为例子: + * 001 - OAuth2 模块 + * 002 - User 模块 + * 003 - MobileCode 模块 + * 第四段,3 位,错误码 + * 不限制规则。 + * 一般建议,每个模块自增。 + * + * @author iailab + */ +public class ServiceErrorCodeRange { + + // 模块 infra 错误码区间 [1-001-000-000 ~ 1-002-000-000) + // 模块 system 错误码区间 [1-002-000-000 ~ 1-003-000-000) + // 模块 report 错误码区间 [1-003-000-000 ~ 1-004-000-000) + // 模块 member 错误码区间 [1-004-000-000 ~ 1-005-000-000) + // 模块 mp 错误码区间 [1-006-000-000 ~ 1-007-000-000) + // 模块 pay 错误码区间 [1-007-000-000 ~ 1-008-000-000) + // 模块 bpm 错误码区间 [1-009-000-000 ~ 1-010-000-000) + + // 模块 product 错误码区间 [1-008-000-000 ~ 1-009-000-000) + // 模块 trade 错误码区间 [1-011-000-000 ~ 1-012-000-000) + // 模块 promotion 错误码区间 [1-013-000-000 ~ 1-014-000-000) + + // 模块 crm 错误码区间 [1-020-000-000 ~ 1-021-000-000) + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/util/ServiceExceptionUtil.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/util/ServiceExceptionUtil.java new file mode 100644 index 0000000..da298fb --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/exception/util/ServiceExceptionUtil.java @@ -0,0 +1,77 @@ +package com.iailab.framework.common.exception.util; + +import com.iailab.framework.common.exception.ErrorCode; +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; + +/** + * {@link ServiceException} 工具类 + * + * 目的在于,格式化异常信息提示。 + * 考虑到 String.format 在参数不正确时会报错,因此使用 {} 作为占位符,并使用 {@link #doFormat(int, String, Object...)} 方法来格式化 + * + */ +@Slf4j +public class ServiceExceptionUtil { + + // ========== 和 ServiceException 的集成 ========== + + public static ServiceException exception(ErrorCode errorCode) { + return exception0(errorCode.getCode(), errorCode.getMsg()); + } + + public static ServiceException exception(ErrorCode errorCode, Object... params) { + return exception0(errorCode.getCode(), errorCode.getMsg(), params); + } + + public static ServiceException exception0(Integer code, String messagePattern, Object... params) { + String message = doFormat(code, messagePattern, params); + return new ServiceException(code, message); + } + + public static ServiceException invalidParamException(String messagePattern, Object... params) { + return exception0(GlobalErrorCodeConstants.BAD_REQUEST.getCode(), messagePattern, params); + } + + // ========== 格式化方法 ========== + + /** + * 将错误编号对应的消息使用 params 进行格式化。 + * + * @param code 错误编号 + * @param messagePattern 消息模版 + * @param params 参数 + * @return 格式化后的提示 + */ + @VisibleForTesting + public static String doFormat(int code, String messagePattern, Object... params) { + StringBuilder sbuf = new StringBuilder(messagePattern.length() + 50); + int i = 0; + int j; + int l; + for (l = 0; l < params.length; l++) { + j = messagePattern.indexOf("{}", i); + if (j == -1) { + log.error("[doFormat][参数过多:错误码({})|错误内容({})|参数({})", code, messagePattern, params); + if (i == 0) { + return messagePattern; + } else { + sbuf.append(messagePattern.substring(i)); + return sbuf.toString(); + } + } else { + sbuf.append(messagePattern, i, j); + sbuf.append(params[l]); + i = j + 2; + } + } + if (messagePattern.indexOf("{}", i) != -1) { + log.error("[doFormat][参数过少:错误码({})|错误内容({})|参数({})", code, messagePattern, params); + } + sbuf.append(messagePattern.substring(i)); + return sbuf.toString(); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/package-info.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/package-info.java new file mode 100644 index 0000000..6c99aa5 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/package-info.java @@ -0,0 +1,6 @@ +/** + * 基础的通用类,和框架无关 + * + * 例如说,CommonResult 为通用返回 + */ +package com.iailab.framework.common; diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/CommonResult.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/CommonResult.java new file mode 100644 index 0000000..147e44d --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/CommonResult.java @@ -0,0 +1,128 @@ +package com.iailab.framework.common.pojo; + +import cn.hutool.core.lang.Assert; +import com.iailab.framework.common.exception.ErrorCode; +import com.iailab.framework.common.exception.ServiceException; +import com.iailab.framework.common.exception.enums.GlobalErrorCodeConstants; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.iailab.framework.common.exception.util.ServiceExceptionUtil; +import lombok.Data; + +import java.io.Serializable; +import java.util.Objects; + +/** + * 通用返回 + * + * @param <T> 数据泛型 + */ +@Data +public class CommonResult<T> implements Serializable { + + /** + * 错误码 + * + * @see ErrorCode#getCode() + */ + private Integer code; + /** + * 返回数据 + */ + private T data; + /** + * 错误提示,用户可阅读 + * + * @see ErrorCode#getMsg() () + */ + private String msg; + + /** + * 将传入的 result 对象,转换成另外一个泛型结果的对象 + * + * 因为 A 方法返回的 CommonResult 对象,不满足调用其的 B 方法的返回,所以需要进行转换。 + * + * @param result 传入的 result 对象 + * @param <T> 返回的泛型 + * @return 新的 CommonResult 对象 + */ + public static <T> CommonResult<T> error(CommonResult<?> result) { + return error(result.getCode(), result.getMsg()); + } + + public static <T> CommonResult<T> error(Integer code, String message) { + cn.hutool.core.lang.Assert.notEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), code, "code 必须是错误的!"); + CommonResult<T> result = new CommonResult<>(); + result.code = code; + result.msg = message; + return result; + } + + public static <T> CommonResult<T> error(ErrorCode errorCode, Object... params) { + Assert.notEquals(GlobalErrorCodeConstants.SUCCESS.getCode(), errorCode.getCode(), "code 必须是错误的!"); + CommonResult<T> result = new CommonResult<>(); + result.code = errorCode.getCode(); + result.msg = ServiceExceptionUtil.doFormat(errorCode.getCode(), errorCode.getMsg(), params); + return result; + } + + public static <T> CommonResult<T> error(ErrorCode errorCode) { + return error(errorCode.getCode(), errorCode.getMsg()); + } + + public static <T> CommonResult<T> success(T data) { + CommonResult<T> result = new CommonResult<>(); + result.code = GlobalErrorCodeConstants.SUCCESS.getCode(); + result.data = data; + result.msg = ""; + return result; + } + + public static CommonResult<String> success() { + CommonResult<String> result = new CommonResult<>(); + result.code = GlobalErrorCodeConstants.SUCCESS.getCode(); + result.msg = "success"; + return result; + } + + public static boolean isSuccess(Integer code) { + return Objects.equals(code, GlobalErrorCodeConstants.SUCCESS.getCode()); + } + + @JsonIgnore // 避免 jackson 序列化 + public boolean isSuccess() { + return isSuccess(code); + } + + @JsonIgnore // 避免 jackson 序列化 + public boolean isError() { + return !isSuccess(); + } + + // ========= 和 Exception 异常体系集成 ========= + + /** + * 判断是否有异常。如果有,则抛出 {@link ServiceException} 异常 + */ + public void checkError() throws ServiceException { + if (isSuccess()) { + return; + } + // 业务异常 + throw new ServiceException(code, msg); + } + + /** + * 判断是否有异常。如果有,则抛出 {@link ServiceException} 异常 + * 如果没有,则返回 {@link #data} 数据 + */ + @JsonIgnore // 避免 jackson 序列化 + public T getCheckedData() { + checkError(); + return data; + } + + public static <T> CommonResult<T> error(ServiceException serviceException) { + return error(serviceException.getCode(), serviceException.getMessage()); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageParam.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageParam.java new file mode 100644 index 0000000..6d7df45 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageParam.java @@ -0,0 +1,36 @@ +package com.iailab.framework.common.pojo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import javax.validation.constraints.Min; +import javax.validation.constraints.Max; +import javax.validation.constraints.NotNull; +import java.io.Serializable; + +@Schema(description="分页参数") +@Data +public class PageParam implements Serializable { + + private static final Integer PAGE_NO = 1; + private static final Integer PAGE_SIZE = 10; + + /** + * 每页条数 - 不分页 + * + * 例如说,导出接口,可以设置 {@link #pageSize} 为 -1 不分页,查询所有数据。 + */ + public static final Integer PAGE_SIZE_NONE = -1; + + @Schema(description = "页码,从 1 开始", requiredMode = Schema.RequiredMode.REQUIRED,example = "1") + @NotNull(message = "页码不能为空") + @Min(value = 1, message = "页码最小值为 1") + private Integer pageNo = PAGE_NO; + + @Schema(description = "每页条数,最大值为 100", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + @NotNull(message = "每页条数不能为空") + @Min(value = 1, message = "每页条数最小值为 1") + @Max(value = 100, message = "每页条数最大值为 100") + private Integer pageSize = PAGE_SIZE; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageResult.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageResult.java new file mode 100644 index 0000000..bee40ce --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/PageResult.java @@ -0,0 +1,41 @@ +package com.iailab.framework.common.pojo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "分页结果") +@Data +public final class PageResult<T> implements Serializable { + + @Schema(description = "数据", requiredMode = Schema.RequiredMode.REQUIRED) + private List<T> list; + + @Schema(description = "总量", requiredMode = Schema.RequiredMode.REQUIRED) + private Long total; + + public PageResult() { + } + + public PageResult(List<T> list, Long total) { + this.list = list; + this.total = total; + } + + public PageResult(Long total) { + this.list = new ArrayList<>(); + this.total = total; + } + + public static <T> PageResult<T> empty() { + return new PageResult<>(0L); + } + + public static <T> PageResult<T> empty(Long total) { + return new PageResult<>(total); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortablePageParam.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortablePageParam.java new file mode 100644 index 0000000..668725f --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortablePageParam.java @@ -0,0 +1,19 @@ +package com.iailab.framework.common.pojo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.List; + +@Schema(description = "可排序的分页参数") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class SortablePageParam extends PageParam { + + @Schema(description = "排序字段") + private List<SortingField> sortingFields; + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortingField.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortingField.java new file mode 100644 index 0000000..3063446 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/pojo/SortingField.java @@ -0,0 +1,37 @@ +package com.iailab.framework.common.pojo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * 排序字段 DTO + * + * 类名加了 ing 的原因是,避免和 ES SortField 重名。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SortingField implements Serializable { + + /** + * 顺序 - 升序 + */ + public static final String ORDER_ASC = "asc"; + /** + * 顺序 - 降序 + */ + public static final String ORDER_DESC = "desc"; + + /** + * 字段 + */ + private String field; + /** + * 顺序 + */ + private String order; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/cache/CacheUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/cache/CacheUtils.java new file mode 100644 index 0000000..528af27 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/cache/CacheUtils.java @@ -0,0 +1,49 @@ +package com.iailab.framework.common.util.cache; + +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; + +import java.time.Duration; +import java.util.concurrent.Executors; + +/** + * Cache 工具类 + * + * @author iailab + */ +public class CacheUtils { + + /** + * 构建异步刷新的 LoadingCache 对象 + * + * 注意:如果你的缓存和 ThreadLocal 有关系,要么自己处理 ThreadLocal 的传递,要么使用 {@link #buildCache(Duration, CacheLoader)} 方法 + * + * 或者简单理解: + * 1、和“人”相关的,使用 {@link #buildCache(Duration, CacheLoader)} 方法 + * 2、和“全局”、“系统”相关的,使用当前缓存方法 + * + * @param duration 过期时间 + * @param loader CacheLoader 对象 + * @return LoadingCache 对象 + */ + public static <K, V> LoadingCache<K, V> buildAsyncReloadingCache(Duration duration, CacheLoader<K, V> loader) { + return CacheBuilder.newBuilder() + // 只阻塞当前数据加载线程,其他线程返回旧值 + .refreshAfterWrite(duration) + // 通过 asyncReloading 实现全异步加载,包括 refreshAfterWrite 被阻塞的加载线程 + .build(CacheLoader.asyncReloading(loader, Executors.newCachedThreadPool())); // TODO iailab:可能要思考下,未来要不要做成可配置 + } + + /** + * 构建同步刷新的 LoadingCache 对象 + * + * @param duration 过期时间 + * @param loader CacheLoader 对象 + * @return LoadingCache 对象 + */ + public static <K, V> LoadingCache<K, V> buildCache(Duration duration, CacheLoader<K, V> loader) { + return CacheBuilder.newBuilder().refreshAfterWrite(duration).build(loader); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/ArrayUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/ArrayUtils.java new file mode 100644 index 0000000..4fba745 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/ArrayUtils.java @@ -0,0 +1,58 @@ +package com.iailab.framework.common.util.collection; + +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.collection.IterUtil; +import cn.hutool.core.util.ArrayUtil; + +import java.util.Collection; +import java.util.function.Consumer; +import java.util.function.Function; + +import static com.iailab.framework.common.util.collection.CollectionUtils.convertList; + +/** + * Array 工具类 + * + * @author iailab + */ +public class ArrayUtils { + + /** + * 将 object 和 newElements 合并成一个数组 + * + * @param object 对象 + * @param newElements 数组 + * @param <T> 泛型 + * @return 结果数组 + */ + @SafeVarargs + public static <T> Consumer<T>[] append(Consumer<T> object, Consumer<T>... newElements) { + if (object == null) { + return newElements; + } + Consumer<T>[] result = ArrayUtil.newArray(Consumer.class, 1 + newElements.length); + result[0] = object; + System.arraycopy(newElements, 0, result, 1, newElements.length); + return result; + } + + public static <T, V> V[] toArray(Collection<T> from, Function<T, V> mapper) { + return toArray(convertList(from, mapper)); + } + + @SuppressWarnings("unchecked") + public static <T> T[] toArray(Collection<T> from) { + if (CollectionUtil.isEmpty(from)) { + return (T[]) (new Object[0]); + } + return ArrayUtil.toArray(from, (Class<T>) IterUtil.getElementType(from.iterator())); + } + + public static <T> T get(T[] array, int index) { + if (null == array || index >= array.length) { + return null; + } + return array[index]; + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/CollectionUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/CollectionUtils.java new file mode 100644 index 0000000..9b972c9 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/CollectionUtils.java @@ -0,0 +1,338 @@ +package com.iailab.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ArrayUtil; +import com.google.common.collect.ImmutableMap; +import com.iailab.framework.common.pojo.PageResult; + +import java.util.*; +import java.util.function.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Arrays.asList; + +/** + * Collection 工具类 + * + * @author iailab + */ +public class CollectionUtils { + + public static boolean containsAny(Object source, Object... targets) { + return asList(targets).contains(source); + } + + public static boolean isAnyEmpty(Collection<?>... collections) { + return Arrays.stream(collections).anyMatch(CollectionUtil::isEmpty); + } + + public static <T> boolean anyMatch(Collection<T> from, Predicate<T> predicate) { + return from.stream().anyMatch(predicate); + } + + public static <T> List<T> filterList(Collection<T> from, Predicate<T> predicate) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().filter(predicate).collect(Collectors.toList()); + } + + public static <T, R> List<T> distinct(Collection<T> from, Function<T, R> keyMapper) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return distinct(from, keyMapper, (t1, t2) -> t1); + } + + public static <T, R> List<T> distinct(Collection<T> from, Function<T, R> keyMapper, BinaryOperator<T> cover) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return new ArrayList<>(convertMap(from, keyMapper, Function.identity(), cover).values()); + } + + public static <T, U> List<U> convertList(T[] from, Function<T, U> func) { + if (ArrayUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return convertList(Arrays.asList(from), func); + } + + public static <T, U> List<U> convertList(Collection<T> from, Function<T, U> func) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static <T, U> List<U> convertList(Collection<T> from, Function<T, U> func, Predicate<T> filter) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static <T, U> PageResult<U> convertPage(PageResult<T> from, Function<T, U> func) { + if (ArrayUtil.isEmpty(from)) { + return new PageResult<>(from.getTotal()); + } + return new PageResult<>(convertList(from.getList(), func), from.getTotal()); + } + + public static <T, U> List<U> convertListByFlatMap(Collection<T> from, + Function<T, ? extends Stream<? extends U>> func) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().filter(Objects::nonNull).flatMap(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static <T, U, R> List<R> convertListByFlatMap(Collection<T> from, + Function<? super T, ? extends U> mapper, + Function<U, ? extends Stream<? extends R>> func) { + if (CollUtil.isEmpty(from)) { + return new ArrayList<>(); + } + return from.stream().map(mapper).filter(Objects::nonNull).flatMap(func).filter(Objects::nonNull).collect(Collectors.toList()); + } + + public static <K, V> List<V> mergeValuesFromMap(Map<K, List<V>> map) { + return map.values() + .stream() + .flatMap(List::stream) + .collect(Collectors.toList()); + } + + public static <T> Set<T> convertSet(Collection<T> from) { + return convertSet(from, v -> v); + } + + public static <T, U> Set<U> convertSet(Collection<T> from, Function<T, U> func) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static <T, U> Set<U> convertSet(Collection<T> from, Function<T, U> func, Predicate<T> filter) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static <T, K> Map<K, T> convertMapByFilter(Collection<T> from, Predicate<T> filter, Function<T, K> keyFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().filter(filter).collect(Collectors.toMap(keyFunc, v -> v)); + } + + public static <T, U> Set<U> convertSetByFlatMap(Collection<T> from, + Function<T, ? extends Stream<? extends U>> func) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().filter(Objects::nonNull).flatMap(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static <T, U, R> Set<R> convertSetByFlatMap(Collection<T> from, + Function<? super T, ? extends U> mapper, + Function<U, ? extends Stream<? extends R>> func) { + if (CollUtil.isEmpty(from)) { + return new HashSet<>(); + } + return from.stream().map(mapper).filter(Objects::nonNull).flatMap(func).filter(Objects::nonNull).collect(Collectors.toSet()); + } + + public static <T, K> Map<K, T> convertMap(Collection<T> from, Function<T, K> keyFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return convertMap(from, keyFunc, Function.identity()); + } + + public static <T, K> Map<K, T> convertMap(Collection<T> from, Function<T, K> keyFunc, Supplier<? extends Map<K, T>> supplier) { + if (CollUtil.isEmpty(from)) { + return supplier.get(); + } + return convertMap(from, keyFunc, Function.identity(), supplier); + } + + public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1); + } + + public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, BinaryOperator<V> mergeFunction) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return convertMap(from, keyFunc, valueFunc, mergeFunction, HashMap::new); + } + + public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, Supplier<? extends Map<K, V>> supplier) { + if (CollUtil.isEmpty(from)) { + return supplier.get(); + } + return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1, supplier); + } + + public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, BinaryOperator<V> mergeFunction, Supplier<? extends Map<K, V>> supplier) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().collect(Collectors.toMap(keyFunc, valueFunc, mergeFunction, supplier)); + } + + public static <T, K> Map<K, List<T>> convertMultiMap(Collection<T> from, Function<T, K> keyFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().collect(Collectors.groupingBy(keyFunc, Collectors.mapping(t -> t, Collectors.toList()))); + } + + public static <T, K, V> Map<K, List<V>> convertMultiMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream() + .collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toList()))); + } + + // 暂时没想好名字,先以 2 结尾噶 + public static <T, K, V> Map<K, Set<V>> convertMultiMap2(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) { + if (CollUtil.isEmpty(from)) { + return new HashMap<>(); + } + return from.stream().collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toSet()))); + } + + public static <T, K> Map<K, T> convertImmutableMap(Collection<T> from, Function<T, K> keyFunc) { + if (CollUtil.isEmpty(from)) { + return Collections.emptyMap(); + } + ImmutableMap.Builder<K, T> builder = ImmutableMap.builder(); + from.forEach(item -> builder.put(keyFunc.apply(item), item)); + return builder.build(); + } + + /** + * 对比老、新两个列表,找出新增、修改、删除的数据 + * + * @param oldList 老列表 + * @param newList 新列表 + * @param sameFunc 对比函数,返回 true 表示相同,返回 false 表示不同 + * 注意,same 是通过每个元素的“标识”,判断它们是不是同一个数据 + * @return [新增列表、修改列表、删除列表] + */ + public static <T> List<List<T>> diffList(Collection<T> oldList, Collection<T> newList, + BiFunction<T, T, Boolean> sameFunc) { + List<T> createList = new LinkedList<>(newList); // 默认都认为是新增的,后续会进行移除 + List<T> updateList = new ArrayList<>(); + List<T> deleteList = new ArrayList<>(); + + // 通过以 oldList 为主遍历,找出 updateList 和 deleteList + for (T oldObj : oldList) { + // 1. 寻找是否有匹配的 + T foundObj = null; + for (Iterator<T> iterator = createList.iterator(); iterator.hasNext(); ) { + T newObj = iterator.next(); + // 1.1 不匹配,则直接跳过 + if (!sameFunc.apply(oldObj, newObj)) { + continue; + } + // 1.2 匹配,则移除,并结束寻找 + iterator.remove(); + foundObj = newObj; + break; + } + // 2. 匹配添加到 updateList;不匹配则添加到 deleteList 中 + if (foundObj != null) { + updateList.add(foundObj); + } else { + deleteList.add(oldObj); + } + } + return asList(createList, updateList, deleteList); + } + + public static boolean containsAny(Collection<?> source, Collection<?> candidates) { + return org.springframework.util.CollectionUtils.containsAny(source, candidates); + } + + public static <T> T getFirst(List<T> from) { + return !CollectionUtil.isEmpty(from) ? from.get(0) : null; + } + + public static <T> T findFirst(Collection<T> from, Predicate<T> predicate) { + return findFirst(from, predicate, Function.identity()); + } + + public static <T, U> U findFirst(Collection<T> from, Predicate<T> predicate, Function<T, U> func) { + if (CollUtil.isEmpty(from)) { + return null; + } + return from.stream().filter(predicate).findFirst().map(func).orElse(null); + } + + public static <T, V extends Comparable<? super V>> V getMaxValue(Collection<T> from, Function<T, V> valueFunc) { + if (CollUtil.isEmpty(from)) { + return null; + } + assert !from.isEmpty(); // 断言,避免告警 + T t = from.stream().max(Comparator.comparing(valueFunc)).get(); + return valueFunc.apply(t); + } + + public static <T, V extends Comparable<? super V>> V getMinValue(List<T> from, Function<T, V> valueFunc) { + if (CollUtil.isEmpty(from)) { + return null; + } + assert from.size() > 0; // 断言,避免告警 + T t = from.stream().min(Comparator.comparing(valueFunc)).get(); + return valueFunc.apply(t); + } + + public static <T, V extends Comparable<? super V>> T getMinObject(List<T> from, Function<T, V> valueFunc) { + if (CollUtil.isEmpty(from)) { + return null; + } + assert from.size() > 0; // 断言,避免告警 + return from.stream().min(Comparator.comparing(valueFunc)).get(); + } + + public static <T, V extends Comparable<? super V>> V getSumValue(Collection<T> from, Function<T, V> valueFunc, + BinaryOperator<V> accumulator) { + return getSumValue(from, valueFunc, accumulator, null); + } + + public static <T, V extends Comparable<? super V>> V getSumValue(Collection<T> from, Function<T, V> valueFunc, + BinaryOperator<V> accumulator, V defaultValue) { + if (CollUtil.isEmpty(from)) { + return defaultValue; + } + assert !from.isEmpty(); // 断言,避免告警 + return from.stream().map(valueFunc).filter(Objects::nonNull).reduce(accumulator).orElse(defaultValue); + } + + public static <T> void addIfNotNull(Collection<T> coll, T item) { + if (item == null) { + return; + } + coll.add(item); + } + + public static <T> Collection<T> singleton(T obj) { + return obj == null ? Collections.emptyList() : Collections.singleton(obj); + } + + public static <T> List<T> newArrayList(List<List<T>> list) { + return list.stream().filter(Objects::nonNull).flatMap(Collection::stream).collect(Collectors.toList()); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/MapUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/MapUtils.java new file mode 100644 index 0000000..921dcbe --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/MapUtils.java @@ -0,0 +1,68 @@ +package com.iailab.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.collection.CollectionUtil; +import cn.hutool.core.util.ObjUtil; +import com.iailab.framework.common.core.KeyValue; +import com.google.common.collect.Maps; +import com.google.common.collect.Multimap; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * Map 工具类 + * + * @author iailab + */ +public class MapUtils { + + /** + * 从哈希表表中,获得 keys 对应的所有 value 数组 + * + * @param multimap 哈希表 + * @param keys keys + * @return value 数组 + */ + public static <K, V> List<V> getList(Multimap<K, V> multimap, Collection<K> keys) { + List<V> result = new ArrayList<>(); + keys.forEach(k -> { + Collection<V> values = multimap.get(k); + if (CollectionUtil.isEmpty(values)) { + return; + } + result.addAll(values); + }); + return result; + } + + /** + * 从哈希表查找到 key 对应的 value,然后进一步处理 + * key 为 null 时, 不处理 + * 注意,如果查找到的 value 为 null 时,不进行处理 + * + * @param map 哈希表 + * @param key key + * @param consumer 进一步处理的逻辑 + */ + public static <K, V> void findAndThen(Map<K, V> map, K key, Consumer<V> consumer) { + if (ObjUtil.isNull(key) || CollUtil.isEmpty(map)) { + return; + } + V value = map.get(key); + if (value == null) { + return; + } + consumer.accept(value); + } + + public static <K, V> Map<K, V> convertMap(List<KeyValue<K, V>> keyValues) { + Map<K, V> map = Maps.newLinkedHashMapWithExpectedSize(keyValues.size()); + keyValues.forEach(keyValue -> map.put(keyValue.getKey(), keyValue.getValue())); + return map; + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/SetUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/SetUtils.java new file mode 100644 index 0000000..8777ee6 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/collection/SetUtils.java @@ -0,0 +1,19 @@ +package com.iailab.framework.common.util.collection; + +import cn.hutool.core.collection.CollUtil; + +import java.util.Set; + +/** + * Set 工具类 + * + * @author iailab + */ +public class SetUtils { + + @SafeVarargs + public static <T> Set<T> asSet(T... objs) { + return CollUtil.newHashSet(objs); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/DateUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/DateUtils.java new file mode 100644 index 0000000..39f8a7e --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/DateUtils.java @@ -0,0 +1,304 @@ +package com.iailab.framework.common.util.date; + +import cn.hutool.core.date.LocalDateTimeUtil; +import org.joda.time.DateTime; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.*; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +/** + * 时间工具类 + * + * @author iailab + */ +public class DateUtils { + + /** + * 时区 - 默认 + */ + public static final String TIME_ZONE_DEFAULT = "GMT+8"; + + /** + * 秒转换成毫秒 + */ + public static final long SECOND_MILLIS = 1000; + + public static final String FORMAT_YEAR_MONTH_DAY = "yyyy-MM-dd"; + /** 时间格式(yyyy.MM.dd) */ + public final static String DATE_PATTERN_POINT = "yyyy.MM.dd"; + + public static final String FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND = "yyyy-MM-dd HH:mm:ss"; + + public final static String DATE_TIME_PATTERN_STRING = "yyyyMMddHHmmss"; + + public static final String FORMAT_SIMPLE_TIME = "HH:mm"; + + /** + * 日期格式化 日期格式为:yyyy-MM-dd + * @param date 日期 + * @return 返回yyyy-MM-dd格式日期 + */ + public static String format(Date date) { + return format(date, FORMAT_YEAR_MONTH_DAY); + } + + /** + * 日期格式化 日期格式为:yyyy-MM-dd + * @param date 日期 + * @param pattern 格式,如:DateUtils.DATE_TIME_PATTERN + * @return 返回yyyy-MM-dd格式日期 + */ + public static String format(Date date, String pattern) { + if(date != null){ + SimpleDateFormat df = new SimpleDateFormat(pattern); + return df.format(date); + } + return null; + } + + /** + * 日期解析 + * @param date 日期 + * @param pattern 格式,如:DateUtils.DATE_TIME_PATTERN + * @return 返回Date + */ + public static Date parse(String date, String pattern) { + try { + return new SimpleDateFormat(pattern).parse(date); + } catch (ParseException e) { + e.printStackTrace(); + } + return null; + } + + /** + * 将 LocalDateTime 转换成 Date + * + * @param date LocalDateTime + * @return LocalDateTime + */ + public static Date of(LocalDateTime date) { + if (date == null) { + return null; + } + // 将此日期时间与时区相结合以创建 ZonedDateTime + ZonedDateTime zonedDateTime = date.atZone(ZoneId.systemDefault()); + // 本地时间线 LocalDateTime 到即时时间线 Instant 时间戳 + Instant instant = zonedDateTime.toInstant(); + // UTC时间(世界协调时间,UTC + 00:00)转北京(北京,UTC + 8:00)时间 + return Date.from(instant); + } + + /** + * 将 Date 转换成 LocalDateTime + * + * @param date Date + * @return LocalDateTime + */ + public static LocalDateTime of(Date date) { + if (date == null) { + return null; + } + // 转为时间戳 + Instant instant = date.toInstant(); + // UTC时间(世界协调时间,UTC + 00:00)转北京(北京,UTC + 8:00)时间 + return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + } + + public static Date addTime(Duration duration) { + return new Date(System.currentTimeMillis() + duration.toMillis()); + } + + public static boolean isExpired(LocalDateTime time) { + LocalDateTime now = LocalDateTime.now(); + return now.isAfter(time); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param mouth 月 + * @param day 日 + * @return 指定时间 + */ + public static Date buildTime(int year, int mouth, int day) { + return buildTime(year, mouth, day, 0, 0, 0); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param mouth 月 + * @param day 日 + * @param hour 小时 + * @param minute 分钟 + * @param second 秒 + * @return 指定时间 + */ + public static Date buildTime(int year, int mouth, int day, + int hour, int minute, int second) { + Calendar calendar = Calendar.getInstance(); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, mouth - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, 0); // 一般情况下,都是 0 毫秒 + return calendar.getTime(); + } + + public static Date max(Date a, Date b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return a.compareTo(b) > 0 ? a : b; + } + + public static LocalDateTime max(LocalDateTime a, LocalDateTime b) { + if (a == null) { + return b; + } + if (b == null) { + return a; + } + return a.isAfter(b) ? a : b; + } + + /** + * 是否今天 + * + * @param date 日期 + * @return 是否 + */ + public static boolean isToday(LocalDateTime date) { + return LocalDateTimeUtil.isSameDay(date, LocalDateTime.now()); + } + + /** + * 是否昨天 + * + * @param date 日期 + * @return 是否 + */ + public static boolean isYesterday(LocalDateTime date) { + return LocalDateTimeUtil.isSameDay(date, LocalDateTime.now().minusDays(1)); + } + + public static List<String> getTimeScale(Date startDate, Date endDate, int seconds) { + List<String> days = new ArrayList<String>(); + Calendar calendar = Calendar.getInstance(); + calendar.setTime(startDate); + while (calendar.getTime().compareTo(endDate) <= 0) { + days.add(DateUtils.format(calendar.getTime(), FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)); + calendar.add(Calendar.SECOND, seconds); + } + return days; + } + + public static List<String> getTimeScale(Date startDate, Date endDate, int seconds, String timeFormat) { + List<String> days = new ArrayList<String>(); + Calendar calendar = Calendar.getInstance(); + calendar.setTime(startDate); + while (calendar.getTime().compareTo(endDate) <= 0) { + days.add(DateUtils.format(calendar.getTime(), timeFormat)); + calendar.add(Calendar.SECOND, seconds); + } + return days; + } + + /** + * 对日期的【秒】进行加/减 + * + * @param date 日期 + * @param seconds 秒数,负数为减 + * @return 加/减几秒后的日期 + */ + public static Date addDateSeconds(Date date, int seconds) { + DateTime dateTime = new DateTime(date); + return dateTime.plusSeconds(seconds).toDate(); + } + + /** + * 对日期的【分钟】进行加/减 + * + * @param date 日期 + * @param minutes 分钟数,负数为减 + * @return 加/减几分钟后的日期 + */ + public static Date addDateMinutes(Date date, int minutes) { + DateTime dateTime = new DateTime(date); + return dateTime.plusMinutes(minutes).toDate(); + } + + /** + * 对日期的【小时】进行加/减 + * + * @param date 日期 + * @param hours 小时数,负数为减 + * @return 加/减几小时后的日期 + */ + public static Date addDateHours(Date date, int hours) { + DateTime dateTime = new DateTime(date); + return dateTime.plusHours(hours).toDate(); + } + + /** + * 对日期的【天】进行加/减 + * + * @param date 日期 + * @param days 天数,负数为减 + * @return 加/减几天后的日期 + */ + public static Date addDateDays(Date date, int days) { + DateTime dateTime = new DateTime(date); + return dateTime.plusDays(days).toDate(); + } + + /** + * 对日期的【周】进行加/减 + * + * @param date 日期 + * @param weeks 周数,负数为减 + * @return 加/减几周后的日期 + */ + public static Date addDateWeeks(Date date, int weeks) { + DateTime dateTime = new DateTime(date); + return dateTime.plusWeeks(weeks).toDate(); + } + + /** + * 对日期的【月】进行加/减 + * + * @param date 日期 + * @param months 月数,负数为减 + * @return 加/减几月后的日期 + */ + public static Date addDateMonths(Date date, int months) { + DateTime dateTime = new DateTime(date); + return dateTime.plusMonths(months).toDate(); + } + + /** + * 对日期的【年】进行加/减 + * + * @param date 日期 + * @param years 年数,负数为减 + * @return 加/减几年后的日期 + */ + public static Date addDateYears(Date date, int years) { + DateTime dateTime = new DateTime(date); + return dateTime.plusYears(years).toDate(); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/LocalDateTimeUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/LocalDateTimeUtils.java new file mode 100644 index 0000000..a54b17c --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/date/LocalDateTimeUtils.java @@ -0,0 +1,309 @@ +package com.iailab.framework.common.util.date; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.enums.DateIntervalEnum; + +import java.time.*; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.List; + +/** + * 时间工具类,用于 {@link java.time.LocalDateTime} + * + * @author iailab + */ +public class LocalDateTimeUtils { + + /** + * 空的 LocalDateTime 对象,主要用于 DB 唯一索引的默认值 + */ + public static LocalDateTime EMPTY = buildTime(1970, 1, 1); + + /** + * 解析时间 + * + * 相比 {@link LocalDateTimeUtil#parse(CharSequence)} 方法来说,会尽量去解析,直到成功 + * + * @param time 时间 + * @return 时间字符串 + */ + public static LocalDateTime parse(String time) { + try { + return LocalDateTimeUtil.parse(time, DatePattern.NORM_DATE_PATTERN); + } catch (DateTimeParseException e) { + return LocalDateTimeUtil.parse(time); + } + } + + public static LocalDateTime addTime(Duration duration) { + return LocalDateTime.now().plus(duration); + } + + public static LocalDateTime minusTime(Duration duration) { + return LocalDateTime.now().minus(duration); + } + + public static boolean beforeNow(LocalDateTime date) { + return date.isBefore(LocalDateTime.now()); + } + + public static boolean afterNow(LocalDateTime date) { + return date.isAfter(LocalDateTime.now()); + } + + /** + * 创建指定时间 + * + * @param year 年 + * @param mouth 月 + * @param day 日 + * @return 指定时间 + */ + public static LocalDateTime buildTime(int year, int mouth, int day) { + return LocalDateTime.of(year, mouth, day, 0, 0, 0); + } + + public static LocalDateTime[] buildBetweenTime(int year1, int mouth1, int day1, + int year2, int mouth2, int day2) { + return new LocalDateTime[]{buildTime(year1, mouth1, day1), buildTime(year2, mouth2, day2)}; + } + + /** + * 判指定断时间,是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param time 指定时间 + * @return 是否 + */ + public static boolean isBetween(LocalDateTime startTime, LocalDateTime endTime, String time) { + if (startTime == null || endTime == null || time == null) { + return false; + } + return LocalDateTimeUtil.isIn(parse(time), startTime, endTime); + } + + /** + * 判断当前时间是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 是否 + */ + public static boolean isBetween(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime == null || endTime == null) { + return false; + } + return LocalDateTimeUtil.isIn(LocalDateTime.now(), startTime, endTime); + } + + /** + * 判断当前时间是否在该时间范围内 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @return 是否 + */ + public static boolean isBetween(String startTime, String endTime) { + if (startTime == null || endTime == null) { + return false; + } + LocalDate nowDate = LocalDate.now(); + return LocalDateTimeUtil.isIn(LocalDateTime.now(), + LocalDateTime.of(nowDate, LocalTime.parse(startTime)), + LocalDateTime.of(nowDate, LocalTime.parse(endTime))); + } + + /** + * 判断时间段是否重叠 + * + * @param startTime1 开始 time1 + * @param endTime1 结束 time1 + * @param startTime2 开始 time2 + * @param endTime2 结束 time2 + * @return 重叠:true 不重叠:false + */ + public static boolean isOverlap(LocalTime startTime1, LocalTime endTime1, LocalTime startTime2, LocalTime endTime2) { + LocalDate nowDate = LocalDate.now(); + return LocalDateTimeUtil.isOverlap(LocalDateTime.of(nowDate, startTime1), LocalDateTime.of(nowDate, endTime1), + LocalDateTime.of(nowDate, startTime2), LocalDateTime.of(nowDate, endTime2)); + } + + /** + * 获取指定日期所在的月份的开始时间 + * 例如:2023-09-30 00:00:00,000 + * + * @param date 日期 + * @return 月份的开始时间 + */ + public static LocalDateTime beginOfMonth(LocalDateTime date) { + return date.with(TemporalAdjusters.firstDayOfMonth()).with(LocalTime.MIN); + } + + /** + * 获取指定日期所在的月份的最后时间 + * 例如:2023-09-30 23:59:59,999 + * + * @param date 日期 + * @return 月份的结束时间 + */ + public static LocalDateTime endOfMonth(LocalDateTime date) { + return date.with(TemporalAdjusters.lastDayOfMonth()).with(LocalTime.MAX); + } + + /** + * 获得指定日期所在季度 + * + * @param date 日期 + * @return 所在季度 + */ + public static int getQuarterOfYear(LocalDateTime date) { + return (date.getMonthValue() - 1) / 3 + 1; + } + + /** + * 获取指定日期到现在过了几天,如果指定日期在当前日期之后,获取结果为负 + * + * @param dateTime 日期 + * @return 相差天数 + */ + public static Long between(LocalDateTime dateTime) { + return LocalDateTimeUtil.between(dateTime, LocalDateTime.now(), ChronoUnit.DAYS); + } + + /** + * 获取今天的开始时间 + * + * @return 今天 + */ + public static LocalDateTime getToday() { + return LocalDateTimeUtil.beginOfDay(LocalDateTime.now()); + } + + /** + * 获取昨天的开始时间 + * + * @return 昨天 + */ + public static LocalDateTime getYesterday() { + return LocalDateTimeUtil.beginOfDay(LocalDateTime.now().minusDays(1)); + } + + /** + * 获取本月的开始时间 + * + * @return 本月 + */ + public static LocalDateTime getMonth() { + return beginOfMonth(LocalDateTime.now()); + } + + /** + * 获取本年的开始时间 + * + * @return 本年 + */ + public static LocalDateTime getYear() { + return LocalDateTime.now().with(TemporalAdjusters.firstDayOfYear()).with(LocalTime.MIN); + } + + public static List<LocalDateTime[]> getDateRangeList(LocalDateTime startTime, + LocalDateTime endTime, + Integer interval) { + // 1.1 找到枚举 + DateIntervalEnum intervalEnum = DateIntervalEnum.valueOf(interval); + Assert.notNull(intervalEnum, "interval({}} 找不到对应的枚举", interval); + // 1.2 将时间对齐 + startTime = LocalDateTimeUtil.beginOfDay(startTime); + endTime = LocalDateTimeUtil.endOfDay(endTime); + + // 2. 循环,生成时间范围 + List<LocalDateTime[]> timeRanges = new ArrayList<>(); + switch (intervalEnum) { + case DAY: + while (startTime.isBefore(endTime)) { + timeRanges.add(new LocalDateTime[]{startTime, startTime.plusDays(1).minusNanos(1)}); + startTime = startTime.plusDays(1); + } + break; + case WEEK: + while (startTime.isBefore(endTime)) { + LocalDateTime endOfWeek = startTime.with(DayOfWeek.SUNDAY).plusDays(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, endOfWeek}); + startTime = endOfWeek.plusNanos(1); + } + break; + case MONTH: + while (startTime.isBefore(endTime)) { + LocalDateTime endOfMonth = startTime.with(TemporalAdjusters.lastDayOfMonth()).plusDays(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, endOfMonth}); + startTime = endOfMonth.plusNanos(1); + } + break; + case QUARTER: + while (startTime.isBefore(endTime)) { + int quarterOfYear = getQuarterOfYear(startTime); + LocalDateTime quarterEnd = quarterOfYear == 4 + ? startTime.with(TemporalAdjusters.lastDayOfYear()).plusDays(1).minusNanos(1) + : startTime.withMonth(quarterOfYear * 3 + 1).withDayOfMonth(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, quarterEnd}); + startTime = quarterEnd.plusNanos(1); + } + break; + case YEAR: + while (startTime.isBefore(endTime)) { + LocalDateTime endOfYear = startTime.with(TemporalAdjusters.lastDayOfYear()).plusDays(1).minusNanos(1); + timeRanges.add(new LocalDateTime[]{startTime, endOfYear}); + startTime = endOfYear.plusNanos(1); + } + break; + default: + throw new IllegalArgumentException("Invalid interval: " + interval); + } + // 3. 兜底,最后一个时间,需要保持在 endTime 之前 + LocalDateTime[] lastTimeRange = CollUtil.getLast(timeRanges); + if (lastTimeRange != null) { + lastTimeRange[1] = endTime; + } + return timeRanges; + } + + /** + * 格式化时间范围 + * + * @param startTime 开始时间 + * @param endTime 结束时间 + * @param interval 时间间隔 + * @return 时间范围 + */ + public static String formatDateRange(LocalDateTime startTime, LocalDateTime endTime, Integer interval) { + // 1. 找到枚举 + DateIntervalEnum intervalEnum = DateIntervalEnum.valueOf(interval); + Assert.notNull(intervalEnum, "interval({}} 找不到对应的枚举", interval); + + // 2. 循环,生成时间范围 + switch (intervalEnum) { + case DAY: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_DATE_PATTERN); + case WEEK: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_DATE_PATTERN) + + StrUtil.format("(第 {} 周)", LocalDateTimeUtil.weekOfYear(startTime)); + case MONTH: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_MONTH_PATTERN); + case QUARTER: + return StrUtil.format("{}-Q{}", startTime.getYear(), getQuarterOfYear(startTime)); + case YEAR: + return LocalDateTimeUtil.format(startTime, DatePattern.NORM_YEAR_PATTERN); + default: + throw new IllegalArgumentException("Invalid interval: " + interval); + } + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/http/HttpUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/http/HttpUtils.java new file mode 100644 index 0000000..1728905 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/http/HttpUtils.java @@ -0,0 +1,385 @@ +package com.iailab.framework.common.util.http; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.map.TableMap; +import cn.hutool.core.net.url.UrlBuilder; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.core.util.StrUtil; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.http.HttpServletRequest; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; + +/** + * HTTP 工具类 + * + * @author iailab + */ +public class HttpUtils { + + @SuppressWarnings("unchecked") + public static String replaceUrlQuery(String url, String key, String value) { + UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset()); + // 先移除 + TableMap<CharSequence, CharSequence> query = (TableMap<CharSequence, CharSequence>) + ReflectUtil.getFieldValue(builder.getQuery(), "query"); + query.remove(key); + // 后添加 + builder.addQuery(key, value); + return builder.build(); + } + + private String append(String base, Map<String, ?> query, boolean fragment) { + return append(base, query, null, fragment); + } + + /** + * 拼接 URL + * + * copy from Spring Security OAuth2 的 AuthorizationEndpoint 类的 append 方法 + * + * @param base 基础 URL + * @param query 查询参数 + * @param keys query 的 key,对应的原本的 key 的映射。例如说 query 里有个 key 是 xx,实际它的 key 是 extra_xx,则通过 keys 里添加这个映射 + * @param fragment URL 的 fragment,即拼接到 # 中 + * @return 拼接后的 URL + */ + public static String append(String base, Map<String, ?> query, Map<String, String> keys, boolean fragment) { + UriComponentsBuilder template = UriComponentsBuilder.newInstance(); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(base); + URI redirectUri; + try { + // assume it's encoded to start with (if it came in over the wire) + redirectUri = builder.build(true).toUri(); + } catch (Exception e) { + // ... but allow client registrations to contain hard-coded non-encoded values + redirectUri = builder.build().toUri(); + builder = UriComponentsBuilder.fromUri(redirectUri); + } + template.scheme(redirectUri.getScheme()).port(redirectUri.getPort()).host(redirectUri.getHost()) + .userInfo(redirectUri.getUserInfo()).path(redirectUri.getPath()); + + if (fragment) { + StringBuilder values = new StringBuilder(); + if (redirectUri.getFragment() != null) { + String append = redirectUri.getFragment(); + values.append(append); + } + for (String key : query.keySet()) { + if (values.length() > 0) { + values.append("&"); + } + String name = key; + if (keys != null && keys.containsKey(key)) { + name = keys.get(key); + } + values.append(name).append("={").append(key).append("}"); + } + if (values.length() > 0) { + template.fragment(values.toString()); + } + UriComponents encoded = template.build().expand(query).encode(); + builder.fragment(encoded.getFragment()); + } else { + for (String key : query.keySet()) { + String name = key; + if (keys != null && keys.containsKey(key)) { + name = keys.get(key); + } + template.queryParam(name, "{" + key + "}"); + } + template.fragment(redirectUri.getFragment()); + UriComponents encoded = template.build().expand(query).encode(); + builder.query(encoded.getQuery()); + } + return builder.build().toUriString(); + } + + public static String[] obtainBasicAuthorization(HttpServletRequest request) { + String clientId; + String clientSecret; + // 先从 Header 中获取 + String authorization = request.getHeader("Authorization"); + authorization = StrUtil.subAfter(authorization, "Basic ", true); + if (StringUtils.hasText(authorization)) { + authorization = Base64.decodeStr(authorization); + clientId = StrUtil.subBefore(authorization, ":", false); + clientSecret = StrUtil.subAfter(authorization, ":", false); + // 再从 Param 中获取 + } else { + clientId = request.getParameter("client_id"); + clientSecret = request.getParameter("client_secret"); + } + + // 如果两者非空,则返回 + if (StrUtil.isNotEmpty(clientId) && StrUtil.isNotEmpty(clientSecret)) { + return new String[]{clientId, clientSecret}; + } + return null; + } + + /** + * 向指定URL发送GET方法的请求 + * + * @param url 发送请求的URL + * @param param 请求参数,请求参数应该是 name1=value1&name2=value2 的形式。 + * @return URL 所代表远程资源的响应结果 + */ + public static String sendGet(String url, String param) { + String result = ""; + BufferedReader in = null; + try { + String urlNameString = url + "?" + param; + URL realUrl = new URL(urlNameString); + // 打开和URL之间的连接 + URLConnection connection = realUrl.openConnection(); + // 设置通用的请求属性 + connection.setRequestProperty("accept", "*/*"); + connection.setRequestProperty("connection", "Keep-Alive"); + connection.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)"); + + // 建立实际的连接 + connection.connect(); + // 获取所有响应头字段 + Map<String, List<String>> map = connection.getHeaderFields(); + // 遍历所有的响应头字段 + for (String key : map.keySet()) { + System.out.println(key + "--->" + map.get(key)); + } + // 定义 BufferedReader输入流来读取URL的响应 + in = new BufferedReader(new InputStreamReader(connection.getInputStream())); + String line; + while ((line = in.readLine()) != null) { + result += line; + } + } catch (Exception e) { + System.out.println("发送GET请求出现异常!" + e); + e.printStackTrace(); + } + // 使用finally块来关闭输入流 + finally { + try { + if (in != null) { + in.close(); + } + } catch (Exception e2) { + e2.printStackTrace(); + } + } + return result; + } + + public static String sendGet(String url, Map<String, String> params, String authorization) { + String result = ""; + BufferedReader in = null; + try { + StringBuilder sb = new StringBuilder(); + sb.append(url); + if (!CollectionUtils.isEmpty(params)) { + sb.append("?"); + params.forEach((k, v) -> { + sb.append(k + "=" + v + "&"); + }); + sb.append("t=" + System.currentTimeMillis()); + } + String urlNameString = sb.toString(); + URL realUrl = new URL(urlNameString); + // 打开和URL之间的连接 + URLConnection connection = realUrl.openConnection(); + // 设置通用的请求属性 + connection.setRequestProperty("Authorization", authorization); + connection.setRequestProperty("accept", "*/*"); + connection.setRequestProperty("connection", "Keep-Alive"); + connection.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)"); + + // 建立实际的连接 + connection.connect(); + // 获取所有响应头字段 + Map<String, List<String>> map = connection.getHeaderFields(); + // 遍历所有的响应头字段 + for (String key : map.keySet()) { + System.out.println(key + "--->" + map.get(key)); + } + // 定义 BufferedReader输入流来读取URL的响应 + in = new BufferedReader(new InputStreamReader(connection.getInputStream())); + String line; + while ((line = in.readLine()) != null) { + result += line; + } + } catch (Exception e) { + System.out.println("发送GET请求出现异常!" + e); + e.printStackTrace(); + } + // 使用finally块来关闭输入流 + finally { + try { + if (in != null) { + in.close(); + } + } catch (Exception e2) { + e2.printStackTrace(); + } + } + return result; + } + + /** + * 向指定 URL 发送POST方法的请求 + * + * @param url + * @param json + * @return + */ + public static String sendPost(String url, String json) { + PrintWriter out = null; + BufferedReader in = null; + String result = ""; + try { + URL realUrl = new URL(url); + // 打开和URL之间的连接 + URLConnection conn = realUrl.openConnection(); + // 设置通用的请求属性 + conn.setRequestProperty("content-type", "application/json"); + conn.setRequestProperty("accept", "*/*"); + conn.setRequestProperty("connection", "Keep-Alive"); + conn.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)"); + + // 发送POST请求必须设置如下两行 + conn.setDoOutput(true); + conn.setDoInput(true); + // 获取URLConnection对象对应的输出流 + out = new PrintWriter(conn.getOutputStream()); + // 发送请求参数 + out.print(json); + // flush输出流的缓冲 + out.flush(); + // 定义BufferedReader输入流来读取URL的响应 + in = new BufferedReader(new InputStreamReader(conn.getInputStream())); + String line; + while ((line = in.readLine()) != null) { + result += line; + } + } catch (Exception e) { + System.out.println("发送 POST 请求出现异常!" + e); + e.printStackTrace(); + } + //使用finally块来关闭输出流、输入流 + finally { + try { + if (out != null) { + out.close(); + } + if (in != null) { + in.close(); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + return result; + } + + /** + * 向指定 URL 发送POST方法的请求 + * + * @param url + * @param json + * @param authorization + * @return + */ + public static String sendPost(String url, String json, String authorization) { + PrintWriter out = null; + BufferedReader in = null; + String result = ""; + try { + URL realUrl = new URL(url); + // 打开和URL之间的连接 + URLConnection conn = realUrl.openConnection(); + // 设置通用的请求属性 + conn.setRequestProperty("Authorization", authorization); + conn.setRequestProperty("accept", "*/*"); + conn.setRequestProperty("connection", "Keep-Alive"); + conn.setRequestProperty("user-agent", "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1;SV1)"); + + // 发送POST请求必须设置如下两行 + conn.setDoOutput(true); + conn.setDoInput(true); + // 获取URLConnection对象对应的输出流 + out = new PrintWriter(conn.getOutputStream()); + // 发送请求参数 + out.print(json); + // flush输出流的缓冲 + out.flush(); + // 定义BufferedReader输入流来读取URL的响应 + in = new BufferedReader(new InputStreamReader(conn.getInputStream())); + String line; + while ((line = in.readLine()) != null) { + result += line; + } + } catch (Exception e) { + System.out.println("发送 POST 请求出现异常!" + e); + e.printStackTrace(); + } + //使用finally块来关闭输出流、输入流 + finally { + try { + if (out != null) { + out.close(); + } + if (in != null) { + in.close(); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + return result; + } + + public static String sendPostToken(String url, String json, String authorization) { + String result = ""; + try { + URL realUrl = new URL(url); + HttpURLConnection connection = (HttpURLConnection) realUrl.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Authorization", authorization); + connection.setDoOutput(true); + + // 发送POST请求的数据 + try (OutputStream os = connection.getOutputStream()) { + os.write(json.getBytes()); + os.flush(); + } + // 获取响应码和响应体 + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { // 200 OK + try (BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream()))) { + StringBuilder response = new StringBuilder(); + String responseLine; + while ((responseLine = br.readLine()) != null) { + response.append(responseLine.trim()); + } + result = response.toString(); + } + } else { + System.out.println("POST request not worked"); + } + } catch (Exception e) { + e.printStackTrace(); + } + return result; + } + + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/FileUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/FileUtils.java new file mode 100644 index 0000000..5356e90 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/FileUtils.java @@ -0,0 +1,84 @@ +package com.iailab.framework.common.util.io; + +import cn.hutool.core.io.FileTypeUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.file.FileNameUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import lombok.SneakyThrows; + +import java.io.ByteArrayInputStream; +import java.io.File; + +/** + * 文件工具类 + * + * @author iailab + */ +public class FileUtils { + + /** + * 创建临时文件 + * 该文件会在 JVM 退出时,进行删除 + * + * @param data 文件内容 + * @return 文件 + */ + @SneakyThrows + public static File createTempFile(String data) { + File file = createTempFile(); + // 写入内容 + FileUtil.writeUtf8String(data, file); + return file; + } + + /** + * 创建临时文件 + * 该文件会在 JVM 退出时,进行删除 + * + * @param data 文件内容 + * @return 文件 + */ + @SneakyThrows + public static File createTempFile(byte[] data) { + File file = createTempFile(); + // 写入内容 + FileUtil.writeBytes(data, file); + return file; + } + + /** + * 创建临时文件,无内容 + * 该文件会在 JVM 退出时,进行删除 + * + * @return 文件 + */ + @SneakyThrows + public static File createTempFile() { + // 创建文件,通过 UUID 保证唯一 + File file = File.createTempFile(IdUtil.simpleUUID(), null); + // 标记 JVM 退出时,自动删除 + file.deleteOnExit(); + return file; + } + + /** + * 生成文件路径 + * + * @param content 文件内容 + * @param originalName 原始文件名 + * @return path,唯一不可重复 + */ + public static String generatePath(byte[] content, String originalName) { + String sha256Hex = DigestUtil.sha256Hex(content); + // 情况一:如果存在 name,则优先使用 name 的后缀 + if (StrUtil.isNotBlank(originalName)) { + String extName = FileNameUtil.extName(originalName); + return StrUtil.isBlank(extName) ? sha256Hex : sha256Hex + "." + extName; + } + // 情况二:基于 content 计算 + return sha256Hex + '.' + FileTypeUtil.getType(new ByteArrayInputStream(content)); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/IoUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/IoUtils.java new file mode 100644 index 0000000..be1d14c --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/io/IoUtils.java @@ -0,0 +1,28 @@ +package com.iailab.framework.common.util.io; + +import cn.hutool.core.io.IORuntimeException; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; + +import java.io.InputStream; + +/** + * IO 工具类,用于 {@link cn.hutool.core.io.IoUtil} 缺失的方法 + * + * @author iailab + */ +public class IoUtils { + + /** + * 从流中读取 UTF8 编码的内容 + * + * @param in 输入流 + * @param isClose 是否关闭 + * @return 内容 + * @throws IORuntimeException IO 异常 + */ + public static String readUtf8(InputStream in, boolean isClose) throws IORuntimeException { + return StrUtil.utf8Str(IoUtil.read(in, isClose)); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/JsonUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/JsonUtils.java new file mode 100644 index 0000000..88e56cd --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/JsonUtils.java @@ -0,0 +1,202 @@ +package com.iailab.framework.common.util.json; + +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +/** + * JSON 工具类 + * + * @author iailab + */ +@Slf4j +public class JsonUtils { + + private static ObjectMapper objectMapper = new ObjectMapper(); + + static { + objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 忽略 null 值 + objectMapper.registerModules(new JavaTimeModule()); // 解决 LocalDateTime 的序列化 + } + + /** + * 初始化 objectMapper 属性 + * <p> + * 通过这样的方式,使用 Spring 创建的 ObjectMapper Bean + * + * @param objectMapper ObjectMapper 对象 + */ + public static void init(ObjectMapper objectMapper) { + JsonUtils.objectMapper = objectMapper; + } + + @SneakyThrows + public static String toJsonString(Object object) { + return objectMapper.writeValueAsString(object); + } + + @SneakyThrows + public static byte[] toJsonByte(Object object) { + return objectMapper.writeValueAsBytes(object); + } + + @SneakyThrows + public static String toJsonPrettyString(Object object) { + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(object); + } + + public static <T> T parseObject(String text, Class<T> clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + return objectMapper.readValue(text, clazz); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static <T> T parseObject(String text, String path, Class<T> clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + JsonNode treeNode = objectMapper.readTree(text); + JsonNode pathNode = treeNode.path(path); + return objectMapper.readValue(pathNode.toString(), clazz); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static <T> T parseObject(String text, Type type) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + return objectMapper.readValue(text, objectMapper.getTypeFactory().constructType(type)); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + /** + * 将字符串解析成指定类型的对象 + * 使用 {@link #parseObject(String, Class)} 时,在@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) 的场景下, + * 如果 text 没有 class 属性,则会报错。此时,使用这个方法,可以解决。 + * + * @param text 字符串 + * @param clazz 类型 + * @return 对象 + */ + public static <T> T parseObject2(String text, Class<T> clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + return JSONUtil.toBean(text, clazz); + } + + public static <T> T parseObject(byte[] bytes, Class<T> clazz) { + if (ArrayUtil.isEmpty(bytes)) { + return null; + } + try { + return objectMapper.readValue(bytes, clazz); + } catch (IOException e) { + log.error("json parse err,json:{}", bytes, e); + throw new RuntimeException(e); + } + } + + public static <T> T parseObject(String text, TypeReference<T> typeReference) { + try { + return objectMapper.readValue(text, typeReference); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + /** + * 解析 JSON 字符串成指定类型的对象,如果解析失败,则返回 null + * + * @param text 字符串 + * @param typeReference 类型引用 + * @return 指定类型的对象 + */ + public static <T> T parseObjectQuietly(String text, TypeReference<T> typeReference) { + try { + return objectMapper.readValue(text, typeReference); + } catch (IOException e) { + return null; + } + } + + public static <T> List<T> parseArray(String text, Class<T> clazz) { + if (StrUtil.isEmpty(text)) { + return new ArrayList<>(); + } + try { + return objectMapper.readValue(text, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz)); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static <T> List<T> parseArray(String text, String path, Class<T> clazz) { + if (StrUtil.isEmpty(text)) { + return null; + } + try { + JsonNode treeNode = objectMapper.readTree(text); + JsonNode pathNode = treeNode.path(path); + return objectMapper.readValue(pathNode.toString(), objectMapper.getTypeFactory().constructCollectionType(List.class, clazz)); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static JsonNode parseTree(String text) { + try { + return objectMapper.readTree(text); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static JsonNode parseTree(byte[] text) { + try { + return objectMapper.readTree(text); + } catch (IOException e) { + log.error("json parse err,json:{}", text, e); + throw new RuntimeException(e); + } + } + + public static boolean isJson(String text) { + return JSONUtil.isTypeJSON(text); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/NumberSerializer.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/NumberSerializer.java new file mode 100644 index 0000000..19d0591 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/NumberSerializer.java @@ -0,0 +1,37 @@ +package com.iailab.framework.common.util.json.databind; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JacksonStdImpl; + +import java.io.IOException; + +/** + * Long 序列化规则 + * + * 会将超长 long 值转换为 string,解决前端 JavaScript 最大安全整数是 2^53-1 的问题 + * + * @author 星语 + */ +@JacksonStdImpl +public class NumberSerializer extends com.fasterxml.jackson.databind.ser.std.NumberSerializer { + + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + private static final long MIN_SAFE_INTEGER = -9007199254740991L; + + public static final NumberSerializer INSTANCE = new NumberSerializer(Number.class); + + public NumberSerializer(Class<? extends Number> rawType) { + super(rawType); + } + + @Override + public void serialize(Number value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // 超出范围 序列化位字符串 + if (value.longValue() > MIN_SAFE_INTEGER && value.longValue() < MAX_SAFE_INTEGER) { + super.serialize(value, gen, serializers); + } else { + gen.writeString(value.toString()); + } + } +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java new file mode 100644 index 0000000..d024c92 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java @@ -0,0 +1,27 @@ +package com.iailab.framework.common.util.json.databind; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 基于时间戳的 LocalDateTime 反序列化器 + * + * @author 老五 + */ +public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> { + + public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer(); + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + // 将 Long 时间戳,转换为 LocalDateTime 对象 + return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getValueAsLong()), ZoneId.systemDefault()); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeSerializer.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeSerializer.java new file mode 100644 index 0000000..6d43528 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/json/databind/TimestampLocalDateTimeSerializer.java @@ -0,0 +1,26 @@ +package com.iailab.framework.common.util.json.databind; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * 基于时间戳的 LocalDateTime 序列化器 + * + * @author 老五 + */ +public class TimestampLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> { + + public static final TimestampLocalDateTimeSerializer INSTANCE = new TimestampLocalDateTimeSerializer(); + + @Override + public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + // 将 LocalDateTime 对象,转换为 Long 时间戳 + gen.writeNumber(value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/monitor/TracerUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/monitor/TracerUtils.java new file mode 100644 index 0000000..e285d44 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/monitor/TracerUtils.java @@ -0,0 +1,30 @@ +package com.iailab.framework.common.util.monitor; + +import org.apache.skywalking.apm.toolkit.trace.TraceContext; + +/** + * 链路追踪工具类 + * + * 考虑到每个 starter 都需要用到该工具类,所以放到 common 模块下的 util 包下 + * + * @author iailab + */ +public class TracerUtils { + + /** + * 私有化构造方法 + */ + private TracerUtils() { + } + + /** + * 获得链路追踪编号,直接返回 SkyWalking 的 TraceId。 + * 如果不存在的话为空字符串!!! + * + * @return 链路追踪编号 + */ + public static String getTraceId() { + return TraceContext.traceId(); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/MoneyUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/MoneyUtils.java new file mode 100644 index 0000000..eb435b7 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/MoneyUtils.java @@ -0,0 +1,131 @@ +package com.iailab.framework.common.util.number; + +import cn.hutool.core.math.Money; +import cn.hutool.core.util.NumberUtil; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 金额工具类 + * + * @author iailab + */ +public class MoneyUtils { + + /** + * 金额的小数位数 + */ + private static final int PRICE_SCALE = 2; + + /** + * 百分比对应的 BigDecimal 对象 + */ + public static final BigDecimal PERCENT_100 = BigDecimal.valueOf(100); + + /** + * 计算百分比金额,四舍五入 + * + * @param price 金额 + * @param rate 百分比,例如说 56.77% 则传入 56.77 + * @return 百分比金额 + */ + public static Integer calculateRatePrice(Integer price, Double rate) { + return calculateRatePrice(price, rate, 0, RoundingMode.HALF_UP).intValue(); + } + + /** + * 计算百分比金额,向下传入 + * + * @param price 金额 + * @param rate 百分比,例如说 56.77% 则传入 56.77 + * @return 百分比金额 + */ + public static Integer calculateRatePriceFloor(Integer price, Double rate) { + return calculateRatePrice(price, rate, 0, RoundingMode.FLOOR).intValue(); + } + + /** + * 计算百分比金额 + * + * @param price 金额(单位分) + * @param count 数量 + * @param percent 折扣(单位分),列如 60.2%,则传入 6020 + * @return 商品总价 + */ + public static Integer calculator(Integer price, Integer count, Integer percent) { + price = price * count; + if (percent == null) { + return price; + } + return MoneyUtils.calculateRatePriceFloor(price, (double) (percent / 100)); + } + + /** + * 计算百分比金额 + * + * @param price 金额 + * @param rate 百分比,例如说 56.77% 则传入 56.77 + * @param scale 保留小数位数 + * @param roundingMode 舍入模式 + */ + public static BigDecimal calculateRatePrice(Number price, Number rate, int scale, RoundingMode roundingMode) { + return NumberUtil.toBigDecimal(price).multiply(NumberUtil.toBigDecimal(rate)) // 乘以 + .divide(BigDecimal.valueOf(100), scale, roundingMode); // 除以 100 + } + + /** + * 分转元 + * + * @param fen 分 + * @return 元 + */ + public static BigDecimal fenToYuan(int fen) { + return new Money(0, fen).getAmount(); + } + + /** + * 分转元(字符串) + * + * 例如说 fen 为 1 时,则结果为 0.01 + * + * @param fen 分 + * @return 元 + */ + public static String fenToYuanStr(int fen) { + return new Money(0, fen).toString(); + } + + /** + * 金额相乘,默认进行四舍五入 + * + * 位数:{@link #PRICE_SCALE} + * + * @param price 金额 + * @param count 数量 + * @return 金额相乘结果 + */ + public static BigDecimal priceMultiply(BigDecimal price, BigDecimal count) { + if (price == null || count == null) { + return null; + } + return price.multiply(count).setScale(PRICE_SCALE, RoundingMode.HALF_UP); + } + + /** + * 金额相乘(百分比),默认进行四舍五入 + * + * 位数:{@link #PRICE_SCALE} + * + * @param price 金额 + * @param percent 百分比 + * @return 金额相乘结果 + */ + public static BigDecimal priceMultiplyPercent(BigDecimal price, BigDecimal percent) { + if (price == null || percent == null) { + return null; + } + return price.multiply(percent).divide(PERCENT_100, PRICE_SCALE, RoundingMode.HALF_UP); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/NumberUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/NumberUtils.java new file mode 100644 index 0000000..f45f673 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/number/NumberUtils.java @@ -0,0 +1,64 @@ +package com.iailab.framework.common.util.number; + +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; + +import java.math.BigDecimal; + +/** + * 数字的工具类,补全 {@link cn.hutool.core.util.NumberUtil} 的功能 + * + * @author iailab + */ +public class NumberUtils { + + public static Long parseLong(String str) { + return StrUtil.isNotEmpty(str) ? Long.valueOf(str) : null; + } + + public static Integer parseInt(String str) { + return StrUtil.isNotEmpty(str) ? Integer.valueOf(str) : null; + } + + /** + * 通过经纬度获取地球上两点之间的距离 + * + * 参考 <<a href="https://gitee.com/dromara/hutool/blob/1caabb586b1f95aec66a21d039c5695df5e0f4c1/hutool-core/src/main/java/cn/hutool/core/util/DistanceUtil.java">DistanceUtil</a>> 实现,目前它已经被 hutool 删除 + * + * @param lat1 经度1 + * @param lng1 纬度1 + * @param lat2 经度2 + * @param lng2 纬度2 + * @return 距离,单位:千米 + */ + public static double getDistance(double lat1, double lng1, double lat2, double lng2) { + double radLat1 = lat1 * Math.PI / 180.0; + double radLat2 = lat2 * Math.PI / 180.0; + double a = radLat1 - radLat2; + double b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0; + double distance = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + + Math.cos(radLat1) * Math.cos(radLat2) + * Math.pow(Math.sin(b / 2), 2))); + distance = distance * 6378.137; + distance = Math.round(distance * 10000d) / 10000d; + return distance; + } + + /** + * 提供精确的乘法运算 + * + * 和 hutool {@link NumberUtil#mul(BigDecimal...)} 的差别是,如果存在 null,则返回 null + * + * @param values 多个被乘值 + * @return 积 + */ + public static BigDecimal mul(BigDecimal... values) { + for (BigDecimal value : values) { + if (value == null) { + return null; + } + } + return NumberUtil.mul(values); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/BeanUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/BeanUtils.java new file mode 100644 index 0000000..80c1beb --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/BeanUtils.java @@ -0,0 +1,69 @@ +package com.iailab.framework.common.util.object; + +import cn.hutool.core.bean.BeanUtil; +import com.iailab.framework.common.pojo.PageResult; +import com.iailab.framework.common.util.collection.CollectionUtils; + +import java.util.List; +import java.util.function.Consumer; + +/** + * Bean 工具类 + * + * 1. 默认使用 {@link cn.hutool.core.bean.BeanUtil} 作为实现类,虽然不同 bean 工具的性能有差别,但是对绝大多数同学的项目,不用在意这点性能 + * 2. 针对复杂的对象转换,可以搜参考 AuthConvert 实现,通过 mapstruct + default 配合实现 + * + * @author iailab + */ +public class BeanUtils { + + public static <T> T toBean(Object source, Class<T> targetClass) { + return BeanUtil.toBean(source, targetClass); + } + + public static <T> T toBean(Object source, Class<T> targetClass, Consumer<T> peek) { + T target = toBean(source, targetClass); + if (target != null) { + peek.accept(target); + } + return target; + } + + public static <S, T> List<T> toBean(List<S> source, Class<T> targetType) { + if (source == null) { + return null; + } + return CollectionUtils.convertList(source, s -> toBean(s, targetType)); + } + + public static <S, T> List<T> toBean(List<S> source, Class<T> targetType, Consumer<T> peek) { + List<T> list = toBean(source, targetType); + if (list != null) { + list.forEach(peek); + } + return list; + } + + public static <S, T> PageResult<T> toBean(PageResult<S> source, Class<T> targetType) { + return toBean(source, targetType, null); + } + + public static <S, T> PageResult<T> toBean(PageResult<S> source, Class<T> targetType, Consumer<T> peek) { + if (source == null) { + return null; + } + List<T> list = toBean(source.getList(), targetType); + if (peek != null) { + list.forEach(peek); + } + return new PageResult<>(list, source.getTotal()); + } + + public static void copyProperties(Object source, Object target) { + if (source == null || target == null) { + return; + } + BeanUtil.copyProperties(source, target, false); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ConvertUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ConvertUtils.java new file mode 100644 index 0000000..4faad98 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ConvertUtils.java @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.util.object; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +/** + * 转换工具类 + * + * @author Mark sunlightcs@gmail.com + */ +public class ConvertUtils { + private static Logger logger = LoggerFactory.getLogger(ConvertUtils.class); + + public static <T> T sourceToTarget(Object source, Class<T> target){ + if(source == null){ + return null; + } + T targetObject = null; + try { + targetObject = target.newInstance(); + BeanUtils.copyProperties(source, targetObject); + } catch (Exception e) { + logger.error("convert error ", e); + } + + return targetObject; + } + + public static <T> List<T> sourceToTarget(Collection<?> sourceList, Class<T> target){ + if(sourceList == null){ + return null; + } + + List targetList = new ArrayList<>(sourceList.size()); + try { + for(Object source : sourceList){ + T targetObject = target.newInstance(); + BeanUtils.copyProperties(source, targetObject); + targetList.add(targetObject); + } + }catch (Exception e){ + logger.error("convert error ", e); + } + + return targetList; + } + /** + * 获取类的所有属性,包括父类 + * + * @param object + * @return + */ + public static Field[] getAllFields(Object object) { + Class<?> clazz = object.getClass(); + List<Field> fieldList = new ArrayList<>(); + while (clazz != null) { + fieldList.addAll(new ArrayList<>(Arrays.asList(clazz.getDeclaredFields()))); + clazz = clazz.getSuperclass(); + } + Field[] fields = new Field[fieldList.size()]; + fieldList.toArray(fields); + return fields; + } +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ObjectUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ObjectUtils.java new file mode 100644 index 0000000..728287e --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/ObjectUtils.java @@ -0,0 +1,63 @@ +package com.iailab.framework.common.util.object; + +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.function.Consumer; + +/** + * Object 工具类 + * + * @author iailab + */ +public class ObjectUtils { + + /** + * 复制对象,并忽略 Id 编号 + * + * @param object 被复制对象 + * @param consumer 消费者,可以二次编辑被复制对象 + * @return 复制后的对象 + */ + public static <T> T cloneIgnoreId(T object, Consumer<T> consumer) { + T result = ObjectUtil.clone(object); + // 忽略 id 编号 + Field field = ReflectUtil.getField(object.getClass(), "id"); + if (field != null) { + ReflectUtil.setFieldValue(result, field, null); + } + // 二次编辑 + if (result != null) { + consumer.accept(result); + } + return result; + } + + public static <T extends Comparable<T>> T max(T obj1, T obj2) { + if (obj1 == null) { + return obj2; + } + if (obj2 == null) { + return obj1; + } + return obj1.compareTo(obj2) > 0 ? obj1 : obj2; + } + + @SafeVarargs + public static <T> T defaultIfNull(T... array) { + for (T item : array) { + if (item != null) { + return item; + } + } + return null; + } + + @SafeVarargs + public static <T> boolean equalsAny(T obj, T... array) { + return Arrays.asList(array).contains(obj); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/PageUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/PageUtils.java new file mode 100644 index 0000000..3476b90 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/object/PageUtils.java @@ -0,0 +1,67 @@ +package com.iailab.framework.common.util.object; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.func.Func1; +import cn.hutool.core.lang.func.LambdaUtil; +import cn.hutool.core.util.ArrayUtil; +import com.iailab.framework.common.pojo.PageParam; +import com.iailab.framework.common.pojo.SortablePageParam; +import com.iailab.framework.common.pojo.SortingField; +import org.springframework.util.Assert; + +import static java.util.Collections.singletonList; + +/** + * {@link com.iailab.framework.common.pojo.PageParam} 工具类 + * + * @author iailab + */ +public class PageUtils { + + private static final Object[] ORDER_TYPES = new String[]{SortingField.ORDER_ASC, SortingField.ORDER_DESC}; + + public static int getStart(PageParam pageParam) { + return (pageParam.getPageNo() - 1) * pageParam.getPageSize(); + } + + /** + * 构建排序字段(默认倒序) + * + * @param func 排序字段的 Lambda 表达式 + * @param <T> 排序字段所属的类型 + * @return 排序字段 + */ + public static <T> SortingField buildSortingField(Func1<T, ?> func) { + return buildSortingField(func, SortingField.ORDER_DESC); + } + + /** + * 构建排序字段 + * + * @param func 排序字段的 Lambda 表达式 + * @param order 排序类型 {@link SortingField#ORDER_ASC} {@link SortingField#ORDER_DESC} + * @param <T> 排序字段所属的类型 + * @return 排序字段 + */ + public static <T> SortingField buildSortingField(Func1<T, ?> func, String order) { + Assert.isTrue(ArrayUtil.contains(ORDER_TYPES, order), String.format("字段的排序类型只能是 %s/%s", ORDER_TYPES)); + + String fieldName = LambdaUtil.getFieldName(func); + return new SortingField(fieldName, order); + } + + /** + * 构建默认的排序字段 + * 如果排序字段为空,则设置排序字段;否则忽略 + * + * @param sortablePageParam 排序分页查询参数 + * @param func 排序字段的 Lambda 表达式 + * @param <T> 排序字段所属的类型 + */ + public static <T> void buildDefaultSortingField(SortablePageParam sortablePageParam, Func1<T, ?> func) { + if (sortablePageParam != null && CollUtil.isEmpty(sortablePageParam.getSortingFields())) { + sortablePageParam.setSortingFields(singletonList(buildSortingField(func))); + } + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/package-info.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/package-info.java new file mode 100644 index 0000000..74c17a4 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/package-info.java @@ -0,0 +1,7 @@ +/** + * 对于工具类的选择,优先查找 Hutool 中有没对应的方法 + * 如果没有,则自己封装对应的工具类,以 Utils 结尾,用于区分 + * + * ps:如果担心 Hutool 存在坑的问题,可以阅读 Hutool 的实现源码,以确保可靠性。并且,可以补充相关的单元测试。 + */ +package com.iailab.framework.common.util; diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/servlet/ServletUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/servlet/ServletUtils.java new file mode 100644 index 0000000..7f12b67 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/servlet/ServletUtils.java @@ -0,0 +1,119 @@ +package com.iailab.framework.common.util.servlet; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.servlet.ServletUtil; +import com.iailab.framework.common.util.json.JsonUtils; +import org.springframework.http.MediaType; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.ServletRequest; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.util.Map; + +/** + * 客户端工具类 + * + * @author iailab + */ +public class ServletUtils { + + /** + * 返回 JSON 字符串 + * + * @param response 响应 + * @param object 对象,会序列化成 JSON 字符串 + */ + @SuppressWarnings("deprecation") // 必须使用 APPLICATION_JSON_UTF8_VALUE,否则会乱码 + public static void writeJSON(HttpServletResponse response, Object object) { + String content = JsonUtils.toJsonString(object); + ServletUtil.write(response, content, MediaType.APPLICATION_JSON_UTF8_VALUE); + } + + /** + * 返回附件 + * + * @param response 响应 + * @param filename 文件名 + * @param content 附件内容 + */ + public static void writeAttachment(HttpServletResponse response, String filename, byte[] content) throws IOException { + // 设置 header 和 contentType + response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename, "UTF-8")); + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + // 输出附件 + IoUtil.write(response.getOutputStream(), false, content); + } + + /** + * @param request 请求 + * @return ua + */ + public static String getUserAgent(HttpServletRequest request) { + String ua = request.getHeader("User-Agent"); + return ua != null ? ua : ""; + } + + /** + * 获得请求 + * + * @return HttpServletRequest + */ + public static HttpServletRequest getRequest() { + RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); + if (!(requestAttributes instanceof ServletRequestAttributes)) { + return null; + } + return ((ServletRequestAttributes) requestAttributes).getRequest(); + } + + public static String getUserAgent() { + HttpServletRequest request = getRequest(); + if (request == null) { + return null; + } + return getUserAgent(request); + } + + public static String getClientIP() { + HttpServletRequest request = getRequest(); + if (request == null) { + return null; + } + return ServletUtil.getClientIP(request); + } + + public static boolean isJsonRequest(ServletRequest request) { + return StrUtil.startWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE); + } + + public static String getBody(HttpServletRequest request) { + // 只有在 json 请求在读取,因为只有 CacheRequestBodyFilter 才会进行缓存,支持重复读取 + if (isJsonRequest(request)) { + return ServletUtil.getBody(request); + } + return null; + } + + public static byte[] getBodyBytes(HttpServletRequest request) { + // 只有在 json 请求在读取,因为只有 CacheRequestBodyFilter 才会进行缓存,支持重复读取 + if (isJsonRequest(request)) { + return ServletUtil.getBodyBytes(request); + } + return null; + } + + public static String getClientIP(HttpServletRequest request) { + return ServletUtil.getClientIP(request); + } + + public static Map<String, String> getParamMap(HttpServletRequest request) { + return ServletUtil.getParamMap(request); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringContextUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringContextUtils.java new file mode 100644 index 0000000..1cc4391 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringContextUtils.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.util.spring; + +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.stereotype.Component; + +/** + * Spring Context 工具类 + * + * @author Mark sunlightcs@gmail.com + */ +@Component +public class SpringContextUtils implements ApplicationContextAware { + public static ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + SpringContextUtils.applicationContext = applicationContext; + } + + public static Object getBean(String name) { + return applicationContext.getBean(name); + } + + public static <T> T getBean(Class<T> requiredType) { + return applicationContext.getBean(requiredType); + } + + public static <T> T getBean(String name, Class<T> requiredType) { + return applicationContext.getBean(name, requiredType); + } + + public static boolean containsBean(String name) { + return applicationContext.containsBean(name); + } + + public static boolean isSingleton(String name) { + return applicationContext.isSingleton(name); + } + + public static Class<? extends Object> getType(String name) { + return applicationContext.getType(name); + } + +} \ No newline at end of file diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringExpressionUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringExpressionUtils.java new file mode 100644 index 0000000..9302a86 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringExpressionUtils.java @@ -0,0 +1,89 @@ +package com.iailab.framework.common.util.spring; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ArrayUtil; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Spring EL 表达式的工具类 + * + * @author mashu + */ +public class SpringExpressionUtils { + + /** + * Spring EL 表达式解析器 + */ + private static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); + /** + * 参数名发现器 + */ + private static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); + + private SpringExpressionUtils() { + } + + /** + * 从切面中,单个解析 EL 表达式的结果 + * + * @param joinPoint 切面点 + * @param expressionString EL 表达式数组 + * @return 执行界面 + */ + public static Object parseExpression(JoinPoint joinPoint, String expressionString) { + Map<String, Object> result = parseExpressions(joinPoint, Collections.singletonList(expressionString)); + return result.get(expressionString); + } + + /** + * 从切面中,批量解析 EL 表达式的结果 + * + * @param joinPoint 切面点 + * @param expressionStrings EL 表达式数组 + * @return 结果,key 为表达式,value 为对应值 + */ + public static Map<String, Object> parseExpressions(JoinPoint joinPoint, List<String> expressionStrings) { + // 如果为空,则不进行解析 + if (CollUtil.isEmpty(expressionStrings)) { + return MapUtil.newHashMap(); + } + + // 第一步,构建解析的上下文 EvaluationContext + // 通过 joinPoint 获取被注解方法 + MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); + Method method = methodSignature.getMethod(); + // 使用 spring 的 ParameterNameDiscoverer 获取方法形参名数组 + String[] paramNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method); + // Spring 的表达式上下文对象 + EvaluationContext context = new StandardEvaluationContext(); + // 给上下文赋值 + if (ArrayUtil.isNotEmpty(paramNames)) { + Object[] args = joinPoint.getArgs(); + for (int i = 0; i < paramNames.length; i++) { + context.setVariable(paramNames[i], args[i]); + } + } + + // 第二步,逐个参数解析 + Map<String, Object> result = MapUtil.newHashMap(expressionStrings.size(), true); + expressionStrings.forEach(key -> { + Object value = EXPRESSION_PARSER.parseExpression(key).getValue(context); + result.put(key, value); + }); + return result; + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringUtils.java new file mode 100644 index 0000000..00ca53b --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/spring/SpringUtils.java @@ -0,0 +1,24 @@ +package com.iailab.framework.common.util.spring; + +import cn.hutool.extra.spring.SpringUtil; + +import java.util.Objects; + +/** + * Spring 工具类 + * + * @author iailab + */ +public class SpringUtils extends SpringUtil { + + /** + * 是否为生产环境 + * + * @return 是否生产环境 + */ + public static boolean isProd() { + String activeProfile = getActiveProfile(); + return Objects.equals("prod", activeProfile); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/string/StrUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/string/StrUtils.java new file mode 100644 index 0000000..31e2c4c --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/string/StrUtils.java @@ -0,0 +1,90 @@ +package com.iailab.framework.common.util.string; + +import cn.hutool.core.text.StrPool; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.StrUtil; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 字符串工具类 + * + * @author iailab + */ +public class StrUtils { + + public static String maxLength(CharSequence str, int maxLength) { + return StrUtil.maxLength(str, maxLength - 3); // -3 的原因,是该方法会补充 ... 恰好 + } + + /** + * 给定字符串是否以任何一个字符串开始 + * 给定字符串和数组为空都返回 false + * + * @param str 给定字符串 + * @param prefixes 需要检测的开始字符串 + * @since 3.0.6 + */ + public static boolean startWithAny(String str, Collection<String> prefixes) { + if (StrUtil.isEmpty(str) || ArrayUtil.isEmpty(prefixes)) { + return false; + } + + for (CharSequence suffix : prefixes) { + if (StrUtil.startWith(str, suffix, false)) { + return true; + } + } + return false; + } + + public static List<Long> splitToLong(String value, CharSequence separator) { + long[] longs = StrUtil.splitToLong(value, separator); + return Arrays.stream(longs).boxed().collect(Collectors.toList()); + } + + public static Set<Long> splitToLongSet(String value) { + return splitToLongSet(value, StrPool.COMMA); + } + + public static Set<Long> splitToLongSet(String value, CharSequence separator) { + long[] longs = StrUtil.splitToLong(value, separator); + return Arrays.stream(longs).boxed().collect(Collectors.toSet()); + } + + public static List<Integer> splitToInteger(String value, CharSequence separator) { + int[] integers = StrUtil.splitToInt(value, separator); + return Arrays.stream(integers).boxed().collect(Collectors.toList()); + } + + /** + * 移除字符串中,包含指定字符串的行 + * + * @param content 字符串 + * @param sequence 包含的字符串 + * @return 移除后的字符串 + */ + public static String removeLineContains(String content, String sequence) { + if (StrUtil.isEmpty(content) || StrUtil.isEmpty(sequence)) { + return content; + } + return Arrays.stream(content.split("\n")) + .filter(line -> !line.contains(sequence)) + .collect(Collectors.joining("\n")); + } + + /** + * 判断字符串是不是数字 + * + * @param str + * @return + */ + public static boolean isNumeric(String str) { + return str.matches("-?\\d+(\\.\\d+)?"); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/validation/ValidationUtils.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/validation/ValidationUtils.java new file mode 100644 index 0000000..d798b49 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/util/validation/ValidationUtils.java @@ -0,0 +1,55 @@ +package com.iailab.framework.common.util.validation; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import org.springframework.util.StringUtils; + +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import javax.validation.Validation; +import javax.validation.Validator; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 校验工具类 + * + * @author iailab + */ +public class ValidationUtils { + + private static final Pattern PATTERN_MOBILE = Pattern.compile("^(?:(?:\\+|00)86)?1(?:(?:3[\\d])|(?:4[0,1,4-9])|(?:5[0-3,5-9])|(?:6[2,5-7])|(?:7[0-8])|(?:8[\\d])|(?:9[0-3,5-9]))\\d{8}$"); + + private static final Pattern PATTERN_URL = Pattern.compile("^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]"); + + private static final Pattern PATTERN_XML_NCNAME = Pattern.compile("[a-zA-Z_][\\-_.0-9_a-zA-Z$]*"); + + public static boolean isMobile(String mobile) { + return StringUtils.hasText(mobile) + && PATTERN_MOBILE.matcher(mobile).matches(); + } + + public static boolean isURL(String url) { + return StringUtils.hasText(url) + && PATTERN_URL.matcher(url).matches(); + } + + public static boolean isXmlNCName(String str) { + return StringUtils.hasText(str) + && PATTERN_XML_NCNAME.matcher(str).matches(); + } + + public static void validate(Object object, Class<?>... groups) { + Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + Assert.notNull(validator); + validate(validator, object, groups); + } + + public static void validate(Validator validator, Object object, Class<?>... groups) { + Set<ConstraintViolation<Object>> constraintViolations = validator.validate(object, groups); + if (CollUtil.isNotEmpty(constraintViolations)) { + throw new ConstraintViolationException(constraintViolations); + } + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnum.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnum.java new file mode 100644 index 0000000..d7bc460 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnum.java @@ -0,0 +1,35 @@ +package com.iailab.framework.common.validation; + +import com.iailab.framework.common.core.IntArrayValuable; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER, + ElementType.TYPE_USE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint( + validatedBy = {InEnumValidator.class, InEnumCollectionValidator.class} +) +public @interface InEnum { + + /** + * @return 实现 EnumValuable 接口的 + */ + Class<? extends IntArrayValuable> value(); + + String message() default "必须在指定范围 {value}"; + + Class<?>[] groups() default {}; + + Class<? extends Payload>[] payload() default {}; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumCollectionValidator.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumCollectionValidator.java new file mode 100644 index 0000000..ce162b2 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumCollectionValidator.java @@ -0,0 +1,42 @@ +package com.iailab.framework.common.validation; + +import cn.hutool.core.collection.CollUtil; +import com.iailab.framework.common.core.IntArrayValuable; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class InEnumCollectionValidator implements ConstraintValidator<InEnum, Collection<Integer>> { + + private List<Integer> values; + + @Override + public void initialize(InEnum annotation) { + IntArrayValuable[] values = annotation.value().getEnumConstants(); + if (values.length == 0) { + this.values = Collections.emptyList(); + } else { + this.values = Arrays.stream(values[0].array()).boxed().collect(Collectors.toList()); + } + } + + @Override + public boolean isValid(Collection<Integer> list, ConstraintValidatorContext context) { + // 校验通过 + if (CollUtil.containsAll(values, list)) { + return true; + } + // 校验不通过,自定义提示语句(因为,注解上的 value 是枚举类,无法获得枚举类的实际值) + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate() + .replaceAll("\\{value}", CollUtil.join(list, ","))).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumValidator.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumValidator.java new file mode 100644 index 0000000..f20b1fb --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/InEnumValidator.java @@ -0,0 +1,44 @@ +package com.iailab.framework.common.validation; + +import com.iailab.framework.common.core.IntArrayValuable; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public class InEnumValidator implements ConstraintValidator<InEnum, Integer> { + + private List<Integer> values; + + @Override + public void initialize(InEnum annotation) { + IntArrayValuable[] values = annotation.value().getEnumConstants(); + if (values.length == 0) { + this.values = Collections.emptyList(); + } else { + this.values = Arrays.stream(values[0].array()).boxed().collect(Collectors.toList()); + } + } + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + // 为空时,默认不校验,即认为通过 + if (value == null) { + return true; + } + // 校验通过 + if (values.contains(value)) { + return true; + } + // 校验不通过,自定义提示语句(因为,注解上的 value 是枚举类,无法获得枚举类的实际值) + context.disableDefaultConstraintViolation(); // 禁用默认的 message 的值 + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate() + .replaceAll("\\{value}", values.toString())).addConstraintViolation(); // 重新添加错误提示语句 + return false; + } + +} + diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Mobile.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Mobile.java new file mode 100644 index 0000000..e1fedfd --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Mobile.java @@ -0,0 +1,28 @@ +package com.iailab.framework.common.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER, + ElementType.TYPE_USE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint( + validatedBy = MobileValidator.class +) +public @interface Mobile { + + String message() default "手机号格式不正确"; + + Class<?>[] groups() default {}; + + Class<? extends Payload>[] payload() default {}; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/MobileValidator.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/MobileValidator.java new file mode 100644 index 0000000..1e0a24a --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/MobileValidator.java @@ -0,0 +1,25 @@ +package com.iailab.framework.common.validation; + +import cn.hutool.core.util.StrUtil; +import com.iailab.framework.common.util.validation.ValidationUtils; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class MobileValidator implements ConstraintValidator<Mobile, String> { + + @Override + public void initialize(Mobile annotation) { + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // 如果手机号为空,默认不校验,即校验通过 + if (StrUtil.isEmpty(value)) { + return true; + } + // 校验手机 + return ValidationUtils.isMobile(value); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Telephone.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Telephone.java new file mode 100644 index 0000000..ccc83e8 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/Telephone.java @@ -0,0 +1,28 @@ +package com.iailab.framework.common.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.*; + +@Target({ + ElementType.METHOD, + ElementType.FIELD, + ElementType.ANNOTATION_TYPE, + ElementType.CONSTRUCTOR, + ElementType.PARAMETER, + ElementType.TYPE_USE +}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Constraint( + validatedBy = TelephoneValidator.class +) +public @interface Telephone { + + String message() default "电话格式不正确"; + + Class<?>[] groups() default {}; + + Class<? extends Payload>[] payload() default {}; + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/TelephoneValidator.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/TelephoneValidator.java new file mode 100644 index 0000000..42b3bfd --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/TelephoneValidator.java @@ -0,0 +1,25 @@ +package com.iailab.framework.common.validation; + +import cn.hutool.core.text.CharSequenceUtil; +import cn.hutool.core.util.PhoneUtil; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class TelephoneValidator implements ConstraintValidator<Telephone, String> { + + @Override + public void initialize(Telephone annotation) { + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // 如果手机号为空,默认不校验,即校验通过 + if (CharSequenceUtil.isEmpty(value)) { + return true; + } + // 校验手机 + return PhoneUtil.isTel(value) || PhoneUtil.isPhone(value); + } + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/AddGroup.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/AddGroup.java new file mode 100644 index 0000000..a453fff --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/AddGroup.java @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.validation.group; + +/** + * 新增 Group + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public interface AddGroup { + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/DefaultGroup.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/DefaultGroup.java new file mode 100644 index 0000000..895e477 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/DefaultGroup.java @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.validation.group; + +/** + * 默认 Group + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public interface DefaultGroup { + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/Group.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/Group.java new file mode 100644 index 0000000..ac31f90 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/Group.java @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.validation.group; + +import javax.validation.GroupSequence; + +/** + * 定义校验顺序,如果AddGroup组失败,则UpdateGroup组不会再校验 + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +@GroupSequence({AddGroup.class, UpdateGroup.class}) +public interface Group { + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/UpdateGroup.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/UpdateGroup.java new file mode 100644 index 0000000..2ab7e74 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/group/UpdateGroup.java @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2018 人人开源 All rights reserved. + * + * https://www.renren.io + * + * 版权所有,侵权必究! + */ + +package com.iailab.framework.common.validation.group; + +/** + * 修改 Group + * + * @author Mark sunlightcs@gmail.com + * @since 1.0.0 + */ +public interface UpdateGroup { + +} diff --git a/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/package-info.java b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/package-info.java new file mode 100644 index 0000000..8fcc528 --- /dev/null +++ b/iailab-framework/iailab-common/src/main/java/com/iailab/framework/common/validation/package-info.java @@ -0,0 +1,4 @@ +/** + * 使用 Hibernate Validator 实现参数校验 + */ +package com.iailab.framework.common.validation; diff --git a/iailab-framework/iailab-common/src/test/java/com/iailab/framework/common/util/collection/CollectionUtilsTest.java b/iailab-framework/iailab-common/src/test/java/com/iailab/framework/common/util/collection/CollectionUtilsTest.java new file mode 100644 index 0000000..335e67c --- /dev/null +++ b/iailab-framework/iailab-common/src/test/java/com/iailab/framework/common/util/collection/CollectionUtilsTest.java @@ -0,0 +1,64 @@ +package com.iailab.framework.common.util.collection; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.BiFunction; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link CollectionUtils} 的单元测试 + */ +public class CollectionUtilsTest { + + @Data + @AllArgsConstructor + private static class Dog { + + private Integer id; + private String name; + private String code; + + } + + @Test + public void testDiffList() { + // 准备参数 + Collection<Dog> oldList = Arrays.asList( + new Dog(1, "花花", "hh"), + new Dog(2, "旺财", "wc") + ); + Collection<Dog> newList = Arrays.asList( + new Dog(null, "花花2", "hh"), + new Dog(null, "小白", "xb") + ); + BiFunction<Dog, Dog, Boolean> sameFunc = (oldObj, newObj) -> { + boolean same = oldObj.getCode().equals(newObj.getCode()); + // 如果相等的情况下,需要设置下 id,后续好更新 + if (same) { + newObj.setId(oldObj.getId()); + } + return same; + }; + + // 调用 + List<List<Dog>> result = CollectionUtils.diffList(oldList, newList, sameFunc); + // 断言 + assertEquals(result.size(), 3); + // 断言 create + assertEquals(result.get(0).size(), 1); + assertEquals(result.get(0).get(0), new Dog(null, "小白", "xb")); + // 断言 update + assertEquals(result.get(1).size(), 1); + assertEquals(result.get(1).get(0), new Dog(1, "花花2", "hh")); + // 断言 delete + assertEquals(result.get(2).size(), 1); + assertEquals(result.get(2).get(0), new Dog(2, "旺财", "wc")); + } + +} diff --git a/iailab-framework/pom.xml b/iailab-framework/pom.xml new file mode 100644 index 0000000..1ca5b3b --- /dev/null +++ b/iailab-framework/pom.xml @@ -0,0 +1,118 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + <parent> + <artifactId>iailab-plat</artifactId> + <groupId>com.iailab</groupId> + <version>${revision}</version> + </parent> + <packaging>pom</packaging> + <modules> + <module>iailab-common</module> + <module>iailab-common-env</module> + <module>iailab-common-mybatis</module> + <module>iailab-common-redis</module> + <module>iailab-common-web</module> + <module>iailab-common-security</module> + <module>iailab-common-websocket</module> + + <module>iailab-common-monitor</module> + <module>iailab-common-protection</module> + <module>iailab-common-job</module> + <module>iailab-common-mq</module> + <module>iailab-common-rpc</module> + + <module>iailab-common-excel</module> + <module>iailab-common-test</module> + + <module>iailab-common-biz-tenant</module> + <module>iailab-common-biz-data-permission</module> + <module>iailab-common-biz-ip</module> + </modules> + + <artifactId>iailab-framework</artifactId> + <description> + 该包是技术组件,每个子包,代表一个组件。每个组件包括两部分: + 1. core 包:是该组件的核心封装 + 2. config 包:是该组件基于 Spring 的配置 + + 技术组件,也分成两类: + 1. 框架组件:和我们熟悉的 MyBatis、Redis 等等的拓展 + 2. 业务组件:和业务相关的组件的封装,例如说数据字典、操作日志等等。 + 如果是业务组件,Maven 名字会包含 biz + </description> + <url>http://172.16.8.100:8888/summary/iailab-plat.git</url> + + <build> + <pluginManagement> + <plugins> + <!-- maven-surefire-plugin 插件,用于运行单元测试。 --> + <!-- 注意,需要使用 3.0.X+,因为要支持 Junit 5 版本 --> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <version>${maven-surefire-plugin.version}</version> + </plugin> + <!-- maven-compiler-plugin 插件,解决 Lombok + MapStruct 组合 --> + <!-- https://stackoverflow.com/questions/33483697/re-run-spring-boot-configuration-annotation-processor-to-update-generated-metada --> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>${maven-compiler-plugin.version}</version> + <configuration> + <annotationProcessorPaths> + <path> + <groupId>org.springframework.boot</groupId> + <artifactId>spring-boot-configuration-processor</artifactId> + <version>${spring.boot.version}</version> + </path> + <path> + <groupId>org.projectlombok</groupId> + <artifactId>lombok</artifactId> + <version>${lombok.version}</version> + </path> + <path> + <groupId>org.mapstruct</groupId> + <artifactId>mapstruct-processor</artifactId> + <version>${mapstruct.version}</version> + </path> + </annotationProcessorPaths> + </configuration> + </plugin> + </plugins> + </pluginManagement> + + <plugins> + <!-- 统一 revision 版本 --> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>flatten-maven-plugin</artifactId> + <version>${flatten-maven-plugin.version}</version> + <configuration> + <flattenMode>resolveCiFriendliesOnly</flattenMode> + <updatePomFile>true</updatePomFile> + </configuration> + <executions> + <execution> + <goals> + <goal>flatten</goal> + </goals> + <id>flatten</id> + <phase>process-resources</phase> + </execution> + <execution> + <goals> + <goal>clean</goal> + </goals> + <id>flatten.clean</id> + <phase>clean</phase> + </execution> + </executions> + </plugin> + + </plugins> + </build> + +</project> -- Gitblit v1.9.3